# threads

id: 019e14b5-7d98-71b0-a05b-3db412dccc83
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
title: $gsd-resume-work spawn the plan checker
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e299a2c6fef833dc5da9646393b0342d0f2f3db1
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
first_user_message: $gsd-resume-work spawn the plan checker
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
preview: $gsd-resume-work spawn the plan checker
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T18-44-59-019e14b5-7d98-71b0-a05b-3db412dccc83.jsonl


id: 019e1512-e36a-72a1-b303-8feda7f326c7
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e299a2c6fef833dc5da9646393b0342d0f2f3db1
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
agent_nickname: Godel
agent_role: gsd-plan-checker
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: subagent
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T20-26-59-019e1512-e36a-72a1-b303-8feda7f326c7.jsonl

source:
  {"subagent":{"thread_spawn":{"parent_thread_id":"019e14b5-7d98-71b0-a05b-3db412dccc83","depth":1,"agent_path":null,"agent_nickname":"Godel","agent_role":"gsd-plan-checker"}}}

title:
  You are the GSD plan checker for REBNO Phase 6 gap-closure plans.

  Goal: Verify that the current on-disk Phase 6 plans 06-10 through 06-17 are executable and achieve the Phase 6 CLI-08 gap-closure goal before Wave 8 execution starts. Review the uncommitted working-tree versions, not just committed history.

  Context to load first:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Specific checks:
  1. Confirm D-28..D-38 from 06-CONTEXT.md Review-Driven Addendum are incorporated into the right plans.
  2. Confirm sequencing/dependencies are coherent: Wave 8 (06-10 || 06-11), Wave 9 (06-12 || 06-13), Wave 10 (06-14), Wave 11 (06-15 then 06-16 if shared GameScene edits require serialization), Wave 12 (06-17).
  3. Confirm 06-14 uses the reduced 20x20 synthetic `mvp-room` scope, not BNCentral, and includes schema discovery plus 44x40 tile dimensions.
  4. Confirm trace tags reference declared IDs and required stages make sense.
  5. Confirm acceptance criteria are testable and tied to CLI-08 closure, not just implementation activity.
  6. Flag any plan/file write conflicts that would make parallel wave execution risky.

  Return a concise report with verdict `GO`, `GO WITH FIXES`, or `BLOCK`, followed by severity-ordered findings with exact file/line references and concrete fix instructions. Do not edit files unless the plan-checker workflow explicitly requires it; this invocation is primarily review/verification.

first_user_message:
  You are the GSD plan checker for REBNO Phase 6 gap-closure plans.

  Goal: Verify that the current on-disk Phase 6 plans 06-10 through 06-17 are executable and achieve the Phase 6 CLI-08 gap-closure goal before Wave 8 execution starts. Review the uncommitted working-tree versions, not just committed history.

  Context to load first:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Specific checks:
  1. Confirm D-28..D-38 from 06-CONTEXT.md Review-Driven Addendum are incorporated into the right plans.
  2. Confirm sequencing/dependencies are coherent: Wave 8 (06-10 || 06-11), Wave 9 (06-12 || 06-13), Wave 10 (06-14), Wave 11 (06-15 then 06-16 if shared GameScene edits require serialization), Wave 12 (06-17).
  3. Confirm 06-14 uses the reduced 20x20 synthetic `mvp-room` scope, not BNCentral, and includes schema discovery plus 44x40 tile dimensions.
  4. Confirm trace tags reference declared IDs and required stages make sense.
  5. Confirm acceptance criteria are testable and tied to CLI-08 closure, not just implementation activity.
  6. Flag any plan/file write conflicts that would make parallel wave execution risky.

  Return a concise report with verdict `GO`, `GO WITH FIXES`, or `BLOCK`, followed by severity-ordered findings with exact file/line references and concrete fix instructions. Do not edit files unless the plan-checker workflow explicitly requires it; this invocation is primarily review/verification.

preview:
  You are the GSD plan checker for REBNO Phase 6 gap-closure plans.

  Goal: Verify that the current on-disk Phase 6 plans 06-10 through 06-17 are executable and achieve the Phase 6 CLI-08 gap-closure goal before Wave 8 execution starts. Review the uncommitted working-tree versions, not just committed history.

  Context to load first:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Specific checks:
  1. Confirm D-28..D-38 from 06-CONTEXT.md Review-Driven Addendum are incorporated into the right plans.
  2. Confirm sequencing/dependencies are coherent: Wave 8 (06-10 || 06-11), Wave 9 (06-12 || 06-13), Wave 10 (06-14), Wave 11 (06-15 then 06-16 if shared GameScene edits require serialization), Wave 12 (06-17).
  3. Confirm 06-14 uses the reduced 20x20 synthetic `mvp-room` scope, not BNCentral, and includes schema discovery plus 44x40 tile dimensions.
  4. Confirm trace tags reference declared IDs and required stages make sense.
  5. Confirm acceptance criteria are testable and tied to CLI-08 closure, not just implementation activity.
  6. Flag any plan/file write conflicts that would make parallel wave execution risky.

  Return a concise report with verdict `GO`, `GO WITH FIXES`, or `BLOCK`, followed by severity-ordered findings with exact file/line references and concrete fix instructions. Do not edit files unless the plan-checker workflow explicitly requires it; this invocation is primarily review/verification.


id: 019e1513-1db5-7350-867c-405882f8c110
source: exec
model_provider: openai
cwd: C:\Users\decid\Documents\projects\claude_skill_owl
sandbox_policy: {"type":"read-only"}
approval_mode: never
git_sha: 6d52c5ea8532ddf6663525921f949c2ae757e467
git_branch: main
git_origin_url: https://github.com/SaberMage/claude_skill_owl.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T20-27-14-019e1513-1db5-7350-867c-405882f8c110.jsonl

title:
  # Cross-AI Plan Review Request — Phase 26 Plan 6 (gap closure)

  You are reviewing a single targeted plan within Phase 26 of an inter-agent messaging Rust project (SPT — Spacetime). Phase 26 ("`/spt:live` Skill — Auto-Pick + Interactive Agent Picker") shipped plans 1–5 as v1.9.8 and underwent user UAT. Plan 6 is the gap-closure plan addressing 5 bugs and 1 doc nit found in UAT.

  Provide structured feedback on Plan 6 specifically — not the whole phase.

  ## Project Context (excerpt from CLAUDE.md)

  SPT is inter-agent messaging + live-agent lifecycle for Claude Code. Ships as the `spt` plugin. The `/spt:live` skill drives auto-pick UX for agent IDs. The auto-pick flow is implemented as:
  - Binary helper `$LIVE pick-spec` emits JSON with `kind: "auto" | "pick" | "prompt-new"`.
  - SKILL.md instructs Claude to parse the JSON and dispatch — `auto` → direct launch; `pick`/`prompt-new` → fire `AskUserQuestion`; free-text input → `$LIVE pick-spec --resolve <name>` → 5-row dispatch table covering D6/D7/D8/D9 collisions.
  - `AskUserQuestion` is a Claude Code primitive that always renders a native free-text "Other" input on top of the options the skill provides.

  ## UAT Results (verbatim from session)

  | # | Status | Bug / Observation |
  |---|--------|-------------------|
  | 1 | PASS | Cold start (0 agents → prompt-new) |
  | 2 | PASS | Single-offline auto-launch |
  | 3 | PASS-WITH-NIT | SKILL Step 2 adds 4th "Other (type a different name)" option, but AskUserQuestion already provides native free-text "Other" → user sees duplicate Other options. |
  | 4 | PASS | Free-text in-repo silent reuse (D6) |
  | 5 | PARTIAL FAIL | (a) D7 confirm body did not surface `other_repos[]` basenames; (b) Cancel from D7 did not re-fire AskUserQuestion — looped back to pick-spec which returned kind:"auto" and auto-launched the agent the user just rejected. |
  | 6 | PASS | Fork dialog (D8) end-to-end, but Cancel branch not exercised. |
  | 7 | FAIL | D9 refuse-with-banner did not re-fire AskUserQuestion. Entry to D9 was via free-text; skill printed banner only, did not loop into picker. |

  VERIFICATION.md flagged a doc nit: REQUIREMENTS.md LIVE-PICK-03 says `$LIVE pick-spec --json` but `--json` flag does not exist (JSON is unconditional).

  ## Plan 6 Under Review

  # Phase 26 Plan 6: UAT gap closure — SKILL flow fixes + REQUIREMENTS wording nit

  **Status:** Not started
  **Phase:** 26 — `/spt:live` Skill — Auto-Pick + Interactive Agent Picker
  **Plan number:** 6 of 6 (added post-UAT)
  **Depends on:** 26-01..26-05 (all shipped + deployed as v1.9.8)
  **Estimated tasks:** 6

  <files_to_read>
  - `.planning/phases/26-spt-live-skill-auto-pick-interactive-agent-picker/26-UAT.md` (7 scenarios + observed verdicts)
  - `.planning/phases/26-spt-live-skill-auto-pick-interactive-agent-picker/26-VERIFICATION.md` (PASS-WITH-FOLLOWUPS; LIVE-PICK-03 doc nit at line 117)
  - `plugin/spt/skills/live/SKILL.md` (current "ID Recollection" at lines 102-153 — target of edits)
  - `src/live/pick_spec.rs` (emitters for `auto`/`pick`/`prompt-new`/`resolve`; verify body+options shape unchanged)
  - `.planning/REQUIREMENTS.md` (LIVE-PICK-03 line 162 — `--json` wording fix)
  </files_to_read>

  <scope>
  **Delivers:**
  1. SKILL.md edits that close 5 UAT-observed gaps in the auto-pick dispatch flow.
  2. REQUIREMENTS.md LIVE-PICK-03 wording fix (`--json` flag does not exist; JSON is unconditional).
  3. Re-run of UAT scenarios 3, 5, 6, 7 against the patched skill to confirm closure.
  4. Plugin version bump + deploy (v1.9.8 → v1.9.9) via `docs/DEPLOY.ps1`.

  **Does NOT deliver:**
  - Changes to `pick_spec.rs` emitter shape — the JSON contract is frozen at v1 per 26-03 (kinds/fields stable). All fixes are skill-instruction and doc-text only.
  - Phase 27/28/29 work — out of scope.

  **Backward compatibility:**
  - Step 1 (`/spt:live <id>` explicit-id path) unchanged.
  - JSON contract emitted by `pick-spec` unchanged.
  - Edits are localized to SKILL.md `## ID Recollection` block (lines 102-153) and REQUIREMENTS.md one-line wording.
  </scope>

  <tasks>

  ### Task 1: SKILL.md — drop redundant "Other (type a different name)" 4th option

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 3 — `AskUserQuestion` natively renders an implicit "Other" free-text option (per tool contract). SKILL Step 2 instructs the skill to add an explicit 4th option labelled "Other (type a different name)", producing a visible duplicate.

  **Steps:**
  1. In SKILL.md Step 2, edit the `kind: "pick"` bullet:
     - Old: `Fire AskUserQuestion using the JSON's header, question, options, and body_addendum fields verbatim. Add a 4th option Other (type a different name) with free-text input.`
     - New: `Fire AskUserQuestion using the JSON's header, question, options, and body_addendum fields verbatim. Do NOT add an explicit "Other" option — AskUserQuestion already provides a native free-text "Other" input; an extra option duplicates it.`
  2. Apply the same edit to the `kind: "prompt-new"` bullet (drop "4th option is free-text as above" → replace with "AskUserQuestion's native free-text is used; do not add an explicit option").

  **Verification:**
  - `git diff plugin/spt/skills/live/SKILL.md` shows ONLY the two bullet edits in Step 2.
  - Native Other behavior re-confirmed during UAT re-run of Scenario 3 (option count = 3, not 4).

  ---

  ### Task 2: SKILL.md — D7 surface `other_repos[]` in body explicitly

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 5a — skill fired `AskUserQuestion` but body did not list the `other_repos` basenames. Dispatch row already says "listing the repo basenames" but the wording was too weak; skill produced an empty-body prompt.

  **Steps:**
  1. Replace the D7 row in the Step 3 dispatch table:
     - Old: `| false | false | non-empty | **Confirm reuse**: fire AskUserQuestion listing the repo basenames; on Reuse here run $LIVE start <name>; on Cancel go back to Step 2 |`
     - New: `| false | false | non-empty | **Confirm reuse**: fire AskUserQuestion with question body containing the literal text "Agent <name> exists in other repos: <comma-joined other_repos>. Reuse it here?" Options: Reuse here / Cancel. On Reuse here run $LIVE start <name>; on Cancel see Task 3 below. |`
  2. The exact verbatim phrasing of the body forces the model to surface the data instead of summarizing it away.

  **Verification:**
  - `git diff` shows only the D7 row text change.
  - UAT re-run Scenario 5: confirm body lists other-repo basenames verbatim.

  ---

  ### Task 3: SKILL.md — Cancel from D7/D8 forces full pick re-entry

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 5b + S5/S6 dispatch rule — after Cancel, skill returned to Step 2 without firing `AskUserQuestion`. If `pick-spec` re-emits `kind:"auto"` (single historical agent), skill auto-launches the agent the user just rejected. Cancel signals "I want something else"; must always present the picker.

  **Steps:**
  1. Add a new subsection after the Step 3 dispatch table titled `### Cancel handling`:
     ```markdown
     ### Cancel handling (after D7 confirm or D8 fork dialog)

     When the user picks **Cancel** in any post-resolve AskUserQuestion, do NOT loop back into `pick-spec` directly. The Cancel is a signal that the user wants to choose a different agent. Re-invoke the picker with **forced presentation**:

     1. Run `$LIVE pick-spec` again to get fresh JSON.
     2. **Treat the result as `kind: "pick"` regardless of what the JSON returns** — even if it would auto (single historical), re-fire `AskUserQuestion` with the candidate(s) as options + body listing all historical agents + native free-text. This prevents auto-launching the agent the user just rejected.
     3. Process the user's choice via Step 3 dispatch as usual.
     ```
  2. Update the D7 row Action wording (already touched in Task 2) so "see Task 3 below" resolves to this Cancel handling subsection (rename to "see Cancel handling below").

  **Verification:**
  - UAT re-run Scenario 5 + Scenario 6 Cancel path: confirm `AskUserQuestion` fires after Cancel, never auto-launches.

  ---

  ### Task 4: SKILL.md — D9 refuse re-fires picker via AskUserQuestion regardless of entry path

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 7 — D9 refuse-with-banner did not re-fire `AskUserQuestion`. Entry to D9 is always via free-text path (Step 3). Skill must re-invoke the picker UI, not just print the banner to the user.

  **Steps:**
  1. Replace the D9 row in the Step 3 dispatch table:
     - Old: `| false | true | non-empty | **Refuse**: re-fire the original picker (Step 2) with the body addendum prepended by ⚠ <name> is already live in <other-repo> — pick something else. |`
     - New: `| false | true | non-empty | **Refuse + re-pick**: run $LIVE pick-spec to get fresh JSON, then fire AskUserQuestion (treat as kind:"pick" forced presentation per Cancel handling) with the body addendum prepended by the literal text "⚠ <name> is already live in <comma-joined other_repos> — pick something else.\n\n". Do NOT skip the AskUserQuestion fire even if pick-spec returns kind:"auto" or kind:"prompt-new". |`
  2. Add a one-line note at the bottom of Step 3 (before `### Notes`):
     ```markdown
     **Forced picker rule:** Cancel-from-D7, Cancel-from-D8, and Refuse-D9 all share the same re-entry path — they re-fire `AskUserQuestion` and never auto-launch.
     ```

  **Verification:**
  - UAT re-run Scenario 7: confirm `AskUserQuestion` fires with banner + options, regardless of whether entry was through free-text or picker selection.

  ---

  ### Task 5: REQUIREMENTS.md — fix LIVE-PICK-03 `--json` wording

  **Files modified:** `.planning/REQUIREMENTS.md`
  **Type:** docs
  **Bug:** VERIFICATION.md line 117 doc nit. LIVE-PICK-03 references `$LIVE pick-spec --json`; the implementation has no `--json` flag (JSON output is unconditional).

  **Steps:**
  1. In `.planning/REQUIREMENTS.md` line 162, replace `Helper $LIVE pick-spec --json produces the AskUserQuestion-ready spec.` with `Helper $LIVE pick-spec produces the AskUserQuestion-ready spec (JSON on stdout, unconditional).`

  **Verification:**
  - `git diff` shows one-line change.
  - `grep -n -- '--json' .planning/REQUIREMENTS.md` returns 0 hits for LIVE-PICK-03 section.

  ---

  ### Task 6: Deploy + UAT re-run for fixed scenarios

  **Files modified:** none (deploy + UAT only — record outcomes in `26-UAT.md` post-run)
  **Type:** test

  **Steps:**
  1. Run `docs/DEPLOY.ps1 -Bump patch` to push v1.9.8 → v1.9.9. Confirm marketplace sync + plugin install reload.
  2. Open fresh Claude Code session to pick up new SKILL.md.
  3. Re-run UAT scenarios 3, 5, 6, 7 from `26-UAT.md`:
     - **S3**: confirm option count = 3 (no duplicate "Other"); native free-text still available.
     - **S5**: confirm body lists `other_repos` basenames verbatim; Cancel re-fires AskUserQuestion (not auto-launch).
     - **S6**: confirm Cancel from fork dialog re-fires AskUserQuestion forced-picker (regression check; S6 already passed but Cancel branch was not exercised).
     - **S7**: confirm refuse banner appears + AskUserQuestion fires; user can pick a different agent.
  4. Record verdicts in `26-UAT.md` as a "Re-run after 26-06" subsection. Mark each scenario PASS / FAIL with one-line evidence.

  **Verification:**
  - All 4 re-run scenarios PASS.
  - If any re-run FAILS, open a new gap-closure plan (26-07) rather than amending 26-06 mid-execute.

  </tasks>

  <security>
  - Edits are docs/markdown only (SKILL.md + REQUIREMENTS.md). No executable surface change. Binary behavior unchanged.
  - Forced-picker re-entry rule does not bypass any `id_validate` guard — free-text resolution still flows through `$LIVE pick-spec --resolve` which validates before any filesystem touch.
  - Cancel handling cannot accidentally launch a different agent than the user picked because the AskUserQuestion fires unconditionally before any `$LIVE start` runs.
  </security>

  <verification>
  **Plan-level acceptance:**
  1. `git diff plugin/spt/skills/live/SKILL.md` shows changes confined to the `## ID Recollection` block (lines 102-153).
  2. `git diff .planning/REQUIREMENTS.md` shows the one-line LIVE-PICK-03 wording fix.
  3. `cargo build --release && cargo test --release -- --test-threads=1` — all tests still pass (no source code changed; this is a sanity check).
  4. Plugin deployed at v1.9.9 via DEPLOY.ps1; marketplace + cache sync confirmed.
  5. UAT scenarios 3, 5, 6, 7 re-run with verdicts recorded in `26-UAT.md`.

  **Phase 26 final closure checklist:**
  - [x] 26-01..26-05 merged + deployed (v1.9.8)
  - [ ] 26-06 SKILL fixes merged
  - [ ] LIVE-PICK-03 wording fix landed
  - [ ] v1.9.9 deployed
  - [ ] All 7 UAT scenarios PASS (4 re-run + 3 already PASS)
  - [ ] `26-SUMMARY.md` written for Phase 26 (rolls up 26-01..26-06)
  - [ ] `26-VERIFICATION.md` re-verified post-fix (status: PASS, gaps_closed populated)
  </verification>

  ## Review Instructions

  Provide:

  1. **Summary** — One-paragraph assessment of Plan 6.
  2. **Strengths** — bullets.
  3. **Concerns** — bullets with severity HIGH/MEDIUM/LOW. Specifically check:
     - Will Tasks 1–4 actually fix the observed bugs? (Skill behavior is driven by SKILL.md prose; assess whether the new prose is unambiguous to a future Claude reader.)
     - Is the "forced picker" rule for Cancel/D9 unambiguous, or could it re-introduce auto-launch in a corner case?
     - Does the D7 body wording change reliably surface `other_repos`? Is there a risk the model still summarizes it away?
     - Are 4 UAT scenarios (3/5/6/7) the right re-run set, or are regressions possible in 1/2/4?
     - Is treating this as a 6th plan inside Phase 26 the right structural choice vs a new sub-phase (e.g., 26.1)?
  4. **Suggestions** — concrete edits to the plan.
  5. **Risk Assessment** — overall risk LOW/MEDIUM/HIGH with justification.

  Be concise and direct. Output markdown.

first_user_message:
  # Cross-AI Plan Review Request — Phase 26 Plan 6 (gap closure)

  You are reviewing a single targeted plan within Phase 26 of an inter-agent messaging Rust project (SPT — Spacetime). Phase 26 ("`/spt:live` Skill — Auto-Pick + Interactive Agent Picker") shipped plans 1–5 as v1.9.8 and underwent user UAT. Plan 6 is the gap-closure plan addressing 5 bugs and 1 doc nit found in UAT.

  Provide structured feedback on Plan 6 specifically — not the whole phase.

  ## Project Context (excerpt from CLAUDE.md)

  SPT is inter-agent messaging + live-agent lifecycle for Claude Code. Ships as the `spt` plugin. The `/spt:live` skill drives auto-pick UX for agent IDs. The auto-pick flow is implemented as:
  - Binary helper `$LIVE pick-spec` emits JSON with `kind: "auto" | "pick" | "prompt-new"`.
  - SKILL.md instructs Claude to parse the JSON and dispatch — `auto` → direct launch; `pick`/`prompt-new` → fire `AskUserQuestion`; free-text input → `$LIVE pick-spec --resolve <name>` → 5-row dispatch table covering D6/D7/D8/D9 collisions.
  - `AskUserQuestion` is a Claude Code primitive that always renders a native free-text "Other" input on top of the options the skill provides.

  ## UAT Results (verbatim from session)

  | # | Status | Bug / Observation |
  |---|--------|-------------------|
  | 1 | PASS | Cold start (0 agents → prompt-new) |
  | 2 | PASS | Single-offline auto-launch |
  | 3 | PASS-WITH-NIT | SKILL Step 2 adds 4th "Other (type a different name)" option, but AskUserQuestion already provides native free-text "Other" → user sees duplicate Other options. |
  | 4 | PASS | Free-text in-repo silent reuse (D6) |
  | 5 | PARTIAL FAIL | (a) D7 confirm body did not surface `other_repos[]` basenames; (b) Cancel from D7 did not re-fire AskUserQuestion — looped back to pick-spec which returned kind:"auto" and auto-launched the agent the user just rejected. |
  | 6 | PASS | Fork dialog (D8) end-to-end, but Cancel branch not exercised. |
  | 7 | FAIL | D9 refuse-with-banner did not re-fire AskUserQuestion. Entry to D9 was via free-text; skill printed banner only, did not loop into picker. |

  VERIFICATION.md flagged a doc nit: REQUIREMENTS.md LIVE-PICK-03 says `$LIVE pick-spec --json` but `--json` flag does not exist (JSON is unconditional).

  ## Plan 6 Under Review

  # Phase 26 Plan 6: UAT gap closure — SKILL flow fixes + REQUIREMENTS wording nit

  **Status:** Not started
  **Phase:** 26 — `/spt:live` Skill — Auto-Pick + Interactive Agent Picker
  **Plan number:** 6 of 6 (added post-UAT)
  **Depends on:** 26-01..26-05 (all shipped + deployed as v1.9.8)
  **Estimated tasks:** 6

  <files_to_read>
  - `.planning/phases/26-spt-live-skill-auto-pick-interactive-agent-picker/26-UAT.md` (7 scenarios + observed verdicts)
  - `.planning/phases/26-spt-live-skill-auto-pick-interactive-agent-picker/26-VERIFICATION.md` (PASS-WITH-FOLLOWUPS; LIVE-PICK-03 doc nit at line 117)
  - `plugin/spt/skills/live/SKILL.md` (current "ID Recollection" at lines 102-153 — target of edits)
  - `src/live/pick_spec.rs` (emitters for `auto`/`pick`/`prompt-new`/`resolve`; verify body+options shape unchanged)
  - `.planning/REQUIREMENTS.md` (LIVE-PICK-03 line 162 — `--json` wording fix)
  </files_to_read>

  <scope>
  **Delivers:**
  1. SKILL.md edits that close 5 UAT-observed gaps in the auto-pick dispatch flow.
  2. REQUIREMENTS.md LIVE-PICK-03 wording fix (`--json` flag does not exist; JSON is unconditional).
  3. Re-run of UAT scenarios 3, 5, 6, 7 against the patched skill to confirm closure.
  4. Plugin version bump + deploy (v1.9.8 → v1.9.9) via `docs/DEPLOY.ps1`.

  **Does NOT deliver:**
  - Changes to `pick_spec.rs` emitter shape — the JSON contract is frozen at v1 per 26-03 (kinds/fields stable). All fixes are skill-instruction and doc-text only.
  - Phase 27/28/29 work — out of scope.

  **Backward compatibility:**
  - Step 1 (`/spt:live <id>` explicit-id path) unchanged.
  - JSON contract emitted by `pick-spec` unchanged.
  - Edits are localized to SKILL.md `## ID Recollection` block (lines 102-153) and REQUIREMENTS.md one-line wording.
  </scope>

  <tasks>

  ### Task 1: SKILL.md — drop redundant "Other (type a different name)" 4th option

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 3 — `AskUserQuestion` natively renders an implicit "Other" free-text option (per tool contract). SKILL Step 2 instructs the skill to add an explicit 4th option labelled "Other (type a different name)", producing a visible duplicate.

  **Steps:**
  1. In SKILL.md Step 2, edit the `kind: "pick"` bullet:
     - Old: `Fire AskUserQuestion using the JSON's header, question, options, and body_addendum fields verbatim. Add a 4th option Other (type a different name) with free-text input.`
     - New: `Fire AskUserQuestion using the JSON's header, question, options, and body_addendum fields verbatim. Do NOT add an explicit "Other" option — AskUserQuestion already provides a native free-text "Other" input; an extra option duplicates it.`
  2. Apply the same edit to the `kind: "prompt-new"` bullet (drop "4th option is free-text as above" → replace with "AskUserQuestion's native free-text is used; do not add an explicit option").

  **Verification:**
  - `git diff plugin/spt/skills/live/SKILL.md` shows ONLY the two bullet edits in Step 2.
  - Native Other behavior re-confirmed during UAT re-run of Scenario 3 (option count = 3, not 4).

  ---

  ### Task 2: SKILL.md — D7 surface `other_repos[]` in body explicitly

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 5a — skill fired `AskUserQuestion` but body did not list the `other_repos` basenames. Dispatch row already says "listing the repo basenames" but the wording was too weak; skill produced an empty-body prompt.

  **Steps:**
  1. Replace the D7 row in the Step 3 dispatch table:
     - Old: `| false | false | non-empty | **Confirm reuse**: fire AskUserQuestion listing the repo basenames; on Reuse here run $LIVE start <name>; on Cancel go back to Step 2 |`
     - New: `| false | false | non-empty | **Confirm reuse**: fire AskUserQuestion with question body containing the literal text "Agent <name> exists in other repos: <comma-joined other_repos>. Reuse it here?" Options: Reuse here / Cancel. On Reuse here run $LIVE start <name>; on Cancel see Task 3 below. |`
  2. The exact verbatim phrasing of the body forces the model to surface the data instead of summarizing it away.

  **Verification:**
  - `git diff` shows only the D7 row text change.
  - UAT re-run Scenario 5: confirm body lists other-repo basenames verbatim.

  ---

  ### Task 3: SKILL.md — Cancel from D7/D8 forces full pick re-entry

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 5b + S5/S6 dispatch rule — after Cancel, skill returned to Step 2 without firing `AskUserQuestion`. If `pick-spec` re-emits `kind:"auto"` (single historical agent), skill auto-launches the agent the user just rejected. Cancel signals "I want something else"; must always present the picker.

  **Steps:**
  1. Add a new subsection after the Step 3 dispatch table titled `### Cancel handling`:
     ```markdown
     ### Cancel handling (after D7 confirm or D8 fork dialog)

     When the user picks **Cancel** in any post-resolve AskUserQuestion, do NOT loop back into `pick-spec` directly. The Cancel is a signal that the user wants to choose a different agent. Re-invoke the picker with **forced presentation**:

     1. Run `$LIVE pick-spec` again to get fresh JSON.
     2. **Treat the result as `kind: "pick"` regardless of what the JSON returns** — even if it would auto (single historical), re-fire `AskUserQuestion` with the candidate(s) as options + body listing all historical agents + native free-text. This prevents auto-launching the agent the user just rejected.
     3. Process the user's choice via Step 3 dispatch as usual.
     ```
  2. Update the D7 row Action wording (already touched in Task 2) so "see Task 3 below" resolves to this Cancel handling subsection (rename to "see Cancel handling below").

  **Verification:**
  - UAT re-run Scenario 5 + Scenario 6 Cancel path: confirm `AskUserQuestion` fires after Cancel, never auto-launches.

  ---

  ### Task 4: SKILL.md — D9 refuse re-fires picker via AskUserQuestion regardless of entry path

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 7 — D9 refuse-with-banner did not re-fire `AskUserQuestion`. Entry to D9 is always via free-text path (Step 3). Skill must re-invoke the picker UI, not just print the banner to the user.

  **Steps:**
  1. Replace the D9 row in the Step 3 dispatch table:
     - Old: `| false | true | non-empty | **Refuse**: re-fire the original picker (Step 2) with the body addendum prepended by ⚠ <name> is already live in <other-repo> — pick something else. |`
     - New: `| false | true | non-empty | **Refuse + re-pick**: run $LIVE pick-spec to get fresh JSON, then fire AskUserQuestion (treat as kind:"pick" forced presentation per Cancel handling) with the body addendum prepended by the literal text "⚠ <name> is already live in <comma-joined other_repos> — pick something else.\n\n". Do NOT skip the AskUserQuestion fire even if pick-spec returns kind:"auto" or kind:"prompt-new". |`
  2. Add a one-line note at the bottom of Step 3 (before `### Notes`):
     ```markdown
     **Forced picker rule:** Cancel-from-D7, Cancel-from-D8, and Refuse-D9 all share the same re-entry path — they re-fire `AskUserQuestion` and never auto-launch.
     ```

  **Verification:**
  - UAT re-run Scenario 7: confirm `AskUserQuestion` fires with banner + options, regardless of whether entry was through free-text or picker selection.

  ---

  ### Task 5: REQUIREMENTS.md — fix LIVE-PICK-03 `--json` wording

  **Files modified:** `.planning/REQUIREMENTS.md`
  **Type:** docs
  **Bug:** VERIFICATION.md line 117 doc nit. LIVE-PICK-03 references `$LIVE pick-spec --json`; the implementation has no `--json` flag (JSON output is unconditional).

  **Steps:**
  1. In `.planning/REQUIREMENTS.md` line 162, replace `Helper $LIVE pick-spec --json produces the AskUserQuestion-ready spec.` with `Helper $LIVE pick-spec produces the AskUserQuestion-ready spec (JSON on stdout, unconditional).`

  **Verification:**
  - `git diff` shows one-line change.
  - `grep -n -- '--json' .planning/REQUIREMENTS.md` returns 0 hits for LIVE-PICK-03 section.

  ---

  ### Task 6: Deploy + UAT re-run for fixed scenarios

  **Files modified:** none (deploy + UAT only — record outcomes in `26-UAT.md` post-run)
  **Type:** test

  **Steps:**
  1. Run `docs/DEPLOY.ps1 -Bump patch` to push v1.9.8 → v1.9.9. Confirm marketplace sync + plugin install reload.
  2. Open fresh Claude Code session to pick up new SKILL.md.
  3. Re-run UAT scenarios 3, 5, 6, 7 from `26-UAT.md`:
     - **S3**: confirm option count = 3 (no duplicate "Other"); native free-text still available.
     - **S5**: confirm body lists `other_repos` basenames verbatim; Cancel re-fires AskUserQuestion (not auto-launch).
     - **S6**: confirm Cancel from fork dialog re-fires AskUserQuestion forced-picker (regression check; S6 already passed but Cancel branch was not exercised).
     - **S7**: confirm refuse banner appears + AskUserQuestion fires; user can pick a different agent.
  4. Record verdicts in `26-UAT.md` as a "Re-run after 26-06" subsection. Mark each scenario PASS / FAIL with one-line evidence.

  **Verification:**
  - All 4 re-run scenarios PASS.
  - If any re-run FAILS, open a new gap-closure plan (26-07) rather than amending 26-06 mid-execute.

  </tasks>

  <security>
  - Edits are docs/markdown only (SKILL.md + REQUIREMENTS.md). No executable surface change. Binary behavior unchanged.
  - Forced-picker re-entry rule does not bypass any `id_validate` guard — free-text resolution still flows through `$LIVE pick-spec --resolve` which validates before any filesystem touch.
  - Cancel handling cannot accidentally launch a different agent than the user picked because the AskUserQuestion fires unconditionally before any `$LIVE start` runs.
  </security>

  <verification>
  **Plan-level acceptance:**
  1. `git diff plugin/spt/skills/live/SKILL.md` shows changes confined to the `## ID Recollection` block (lines 102-153).
  2. `git diff .planning/REQUIREMENTS.md` shows the one-line LIVE-PICK-03 wording fix.
  3. `cargo build --release && cargo test --release -- --test-threads=1` — all tests still pass (no source code changed; this is a sanity check).
  4. Plugin deployed at v1.9.9 via DEPLOY.ps1; marketplace + cache sync confirmed.
  5. UAT scenarios 3, 5, 6, 7 re-run with verdicts recorded in `26-UAT.md`.

  **Phase 26 final closure checklist:**
  - [x] 26-01..26-05 merged + deployed (v1.9.8)
  - [ ] 26-06 SKILL fixes merged
  - [ ] LIVE-PICK-03 wording fix landed
  - [ ] v1.9.9 deployed
  - [ ] All 7 UAT scenarios PASS (4 re-run + 3 already PASS)
  - [ ] `26-SUMMARY.md` written for Phase 26 (rolls up 26-01..26-06)
  - [ ] `26-VERIFICATION.md` re-verified post-fix (status: PASS, gaps_closed populated)
  </verification>

  ## Review Instructions

  Provide:

  1. **Summary** — One-paragraph assessment of Plan 6.
  2. **Strengths** — bullets.
  3. **Concerns** — bullets with severity HIGH/MEDIUM/LOW. Specifically check:
     - Will Tasks 1–4 actually fix the observed bugs? (Skill behavior is driven by SKILL.md prose; assess whether the new prose is unambiguous to a future Claude reader.)
     - Is the "forced picker" rule for Cancel/D9 unambiguous, or could it re-introduce auto-launch in a corner case?
     - Does the D7 body wording change reliably surface `other_repos`? Is there a risk the model still summarizes it away?
     - Are 4 UAT scenarios (3/5/6/7) the right re-run set, or are regressions possible in 1/2/4?
     - Is treating this as a 6th plan inside Phase 26 the right structural choice vs a new sub-phase (e.g., 26.1)?
  4. **Suggestions** — concrete edits to the plan.
  5. **Risk Assessment** — overall risk LOW/MEDIUM/HIGH with justification.

  Be concise and direct. Output markdown.

preview:
  # Cross-AI Plan Review Request — Phase 26 Plan 6 (gap closure)

  You are reviewing a single targeted plan within Phase 26 of an inter-agent messaging Rust project (SPT — Spacetime). Phase 26 ("`/spt:live` Skill — Auto-Pick + Interactive Agent Picker") shipped plans 1–5 as v1.9.8 and underwent user UAT. Plan 6 is the gap-closure plan addressing 5 bugs and 1 doc nit found in UAT.

  Provide structured feedback on Plan 6 specifically — not the whole phase.

  ## Project Context (excerpt from CLAUDE.md)

  SPT is inter-agent messaging + live-agent lifecycle for Claude Code. Ships as the `spt` plugin. The `/spt:live` skill drives auto-pick UX for agent IDs. The auto-pick flow is implemented as:
  - Binary helper `$LIVE pick-spec` emits JSON with `kind: "auto" | "pick" | "prompt-new"`.
  - SKILL.md instructs Claude to parse the JSON and dispatch — `auto` → direct launch; `pick`/`prompt-new` → fire `AskUserQuestion`; free-text input → `$LIVE pick-spec --resolve <name>` → 5-row dispatch table covering D6/D7/D8/D9 collisions.
  - `AskUserQuestion` is a Claude Code primitive that always renders a native free-text "Other" input on top of the options the skill provides.

  ## UAT Results (verbatim from session)

  | # | Status | Bug / Observation |
  |---|--------|-------------------|
  | 1 | PASS | Cold start (0 agents → prompt-new) |
  | 2 | PASS | Single-offline auto-launch |
  | 3 | PASS-WITH-NIT | SKILL Step 2 adds 4th "Other (type a different name)" option, but AskUserQuestion already provides native free-text "Other" → user sees duplicate Other options. |
  | 4 | PASS | Free-text in-repo silent reuse (D6) |
  | 5 | PARTIAL FAIL | (a) D7 confirm body did not surface `other_repos[]` basenames; (b) Cancel from D7 did not re-fire AskUserQuestion — looped back to pick-spec which returned kind:"auto" and auto-launched the agent the user just rejected. |
  | 6 | PASS | Fork dialog (D8) end-to-end, but Cancel branch not exercised. |
  | 7 | FAIL | D9 refuse-with-banner did not re-fire AskUserQuestion. Entry to D9 was via free-text; skill printed banner only, did not loop into picker. |

  VERIFICATION.md flagged a doc nit: REQUIREMENTS.md LIVE-PICK-03 says `$LIVE pick-spec --json` but `--json` flag does not exist (JSON is unconditional).

  ## Plan 6 Under Review

  # Phase 26 Plan 6: UAT gap closure — SKILL flow fixes + REQUIREMENTS wording nit

  **Status:** Not started
  **Phase:** 26 — `/spt:live` Skill — Auto-Pick + Interactive Agent Picker
  **Plan number:** 6 of 6 (added post-UAT)
  **Depends on:** 26-01..26-05 (all shipped + deployed as v1.9.8)
  **Estimated tasks:** 6

  <files_to_read>
  - `.planning/phases/26-spt-live-skill-auto-pick-interactive-agent-picker/26-UAT.md` (7 scenarios + observed verdicts)
  - `.planning/phases/26-spt-live-skill-auto-pick-interactive-agent-picker/26-VERIFICATION.md` (PASS-WITH-FOLLOWUPS; LIVE-PICK-03 doc nit at line 117)
  - `plugin/spt/skills/live/SKILL.md` (current "ID Recollection" at lines 102-153 — target of edits)
  - `src/live/pick_spec.rs` (emitters for `auto`/`pick`/`prompt-new`/`resolve`; verify body+options shape unchanged)
  - `.planning/REQUIREMENTS.md` (LIVE-PICK-03 line 162 — `--json` wording fix)
  </files_to_read>

  <scope>
  **Delivers:**
  1. SKILL.md edits that close 5 UAT-observed gaps in the auto-pick dispatch flow.
  2. REQUIREMENTS.md LIVE-PICK-03 wording fix (`--json` flag does not exist; JSON is unconditional).
  3. Re-run of UAT scenarios 3, 5, 6, 7 against the patched skill to confirm closure.
  4. Plugin version bump + deploy (v1.9.8 → v1.9.9) via `docs/DEPLOY.ps1`.

  **Does NOT deliver:**
  - Changes to `pick_spec.rs` emitter shape — the JSON contract is frozen at v1 per 26-03 (kinds/fields stable). All fixes are skill-instruction and doc-text only.
  - Phase 27/28/29 work — out of scope.

  **Backward compatibility:**
  - Step 1 (`/spt:live <id>` explicit-id path) unchanged.
  - JSON contract emitted by `pick-spec` unchanged.
  - Edits are localized to SKILL.md `## ID Recollection` block (lines 102-153) and REQUIREMENTS.md one-line wording.
  </scope>

  <tasks>

  ### Task 1: SKILL.md — drop redundant "Other (type a different name)" 4th option

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 3 — `AskUserQuestion` natively renders an implicit "Other" free-text option (per tool contract). SKILL Step 2 instructs the skill to add an explicit 4th option labelled "Other (type a different name)", producing a visible duplicate.

  **Steps:**
  1. In SKILL.md Step 2, edit the `kind: "pick"` bullet:
     - Old: `Fire AskUserQuestion using the JSON's header, question, options, and body_addendum fields verbatim. Add a 4th option Other (type a different name) with free-text input.`
     - New: `Fire AskUserQuestion using the JSON's header, question, options, and body_addendum fields verbatim. Do NOT add an explicit "Other" option — AskUserQuestion already provides a native free-text "Other" input; an extra option duplicates it.`
  2. Apply the same edit to the `kind: "prompt-new"` bullet (drop "4th option is free-text as above" → replace with "AskUserQuestion's native free-text is used; do not add an explicit option").

  **Verification:**
  - `git diff plugin/spt/skills/live/SKILL.md` shows ONLY the two bullet edits in Step 2.
  - Native Other behavior re-confirmed during UAT re-run of Scenario 3 (option count = 3, not 4).

  ---

  ### Task 2: SKILL.md — D7 surface `other_repos[]` in body explicitly

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 5a — skill fired `AskUserQuestion` but body did not list the `other_repos` basenames. Dispatch row already says "listing the repo basenames" but the wording was too weak; skill produced an empty-body prompt.

  **Steps:**
  1. Replace the D7 row in the Step 3 dispatch table:
     - Old: `| false | false | non-empty | **Confirm reuse**: fire AskUserQuestion listing the repo basenames; on Reuse here run $LIVE start <name>; on Cancel go back to Step 2 |`
     - New: `| false | false | non-empty | **Confirm reuse**: fire AskUserQuestion with question body containing the literal text "Agent <name> exists in other repos: <comma-joined other_repos>. Reuse it here?" Options: Reuse here / Cancel. On Reuse here run $LIVE start <name>; on Cancel see Task 3 below. |`
  2. The exact verbatim phrasing of the body forces the model to surface the data instead of summarizing it away.

  **Verification:**
  - `git diff` shows only the D7 row text change.
  - UAT re-run Scenario 5: confirm body lists other-repo basenames verbatim.

  ---

  ### Task 3: SKILL.md — Cancel from D7/D8 forces full pick re-entry

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 5b + S5/S6 dispatch rule — after Cancel, skill returned to Step 2 without firing `AskUserQuestion`. If `pick-spec` re-emits `kind:"auto"` (single historical agent), skill auto-launches the agent the user just rejected. Cancel signals "I want something else"; must always present the picker.

  **Steps:**
  1. Add a new subsection after the Step 3 dispatch table titled `### Cancel handling`:
     ```markdown
     ### Cancel handling (after D7 confirm or D8 fork dialog)

     When the user picks **Cancel** in any post-resolve AskUserQuestion, do NOT loop back into `pick-spec` directly. The Cancel is a signal that the user wants to choose a different agent. Re-invoke the picker with **forced presentation**:

     1. Run `$LIVE pick-spec` again to get fresh JSON.
     2. **Treat the result as `kind: "pick"` regardless of what the JSON returns** — even if it would auto (single historical), re-fire `AskUserQuestion` with the candidate(s) as options + body listing all historical agents + native free-text. This prevents auto-launching the agent the user just rejected.
     3. Process the user's choice via Step 3 dispatch as usual.
     ```
  2. Update the D7 row Action wording (already touched in Task 2) so "see Task 3 below" resolves to this Cancel handling subsection (rename to "see Cancel handling below").

  **Verification:**
  - UAT re-run Scenario 5 + Scenario 6 Cancel path: confirm `AskUserQuestion` fires after Cancel, never auto-launches.

  ---

  ### Task 4: SKILL.md — D9 refuse re-fires picker via AskUserQuestion regardless of entry path

  **Files modified:** `plugin/spt/skills/live/SKILL.md`
  **Type:** docs
  **Bug:** UAT Scenario 7 — D9 refuse-with-banner did not re-fire `AskUserQuestion`. Entry to D9 is always via free-text path (Step 3). Skill must re-invoke the picker UI, not just print the banner to the user.

  **Steps:**
  1. Replace the D9 row in the Step 3 dispatch table:
     - Old: `| false | true | non-empty | **Refuse**: re-fire the original picker (Step 2) with the body addendum prepended by ⚠ <name> is already live in <other-repo> — pick something else. |`
     - New: `| false | true | non-empty | **Refuse + re-pick**: run $LIVE pick-spec to get fresh JSON, then fire AskUserQuestion (treat as kind:"pick" forced presentation per Cancel handling) with the body addendum prepended by the literal text "⚠ <name> is already live in <comma-joined other_repos> — pick something else.\n\n". Do NOT skip the AskUserQuestion fire even if pick-spec returns kind:"auto" or kind:"prompt-new". |`
  2. Add a one-line note at the bottom of Step 3 (before `### Notes`):
     ```markdown
     **Forced picker rule:** Cancel-from-D7, Cancel-from-D8, and Refuse-D9 all share the same re-entry path — they re-fire `AskUserQuestion` and never auto-launch.
     ```

  **Verification:**
  - UAT re-run Scenario 7: confirm `AskUserQuestion` fires with banner + options, regardless of whether entry was through free-text or picker selection.

  ---

  ### Task 5: REQUIREMENTS.md — fix LIVE-PICK-03 `--json` wording

  **Files modified:** `.planning/REQUIREMENTS.md`
  **Type:** docs
  **Bug:** VERIFICATION.md line 117 doc nit. LIVE-PICK-03 references `$LIVE pick-spec --json`; the implementation has no `--json` flag (JSON output is unconditional).

  **Steps:**
  1. In `.planning/REQUIREMENTS.md` line 162, replace `Helper $LIVE pick-spec --json produces the AskUserQuestion-ready spec.` with `Helper $LIVE pick-spec produces the AskUserQuestion-ready spec (JSON on stdout, unconditional).`

  **Verification:**
  - `git diff` shows one-line change.
  - `grep -n -- '--json' .planning/REQUIREMENTS.md` returns 0 hits for LIVE-PICK-03 section.

  ---

  ### Task 6: Deploy + UAT re-run for fixed scenarios

  **Files modified:** none (deploy + UAT only — record outcomes in `26-UAT.md` post-run)
  **Type:** test

  **Steps:**
  1. Run `docs/DEPLOY.ps1 -Bump patch` to push v1.9.8 → v1.9.9. Confirm marketplace sync + plugin install reload.
  2. Open fresh Claude Code session to pick up new SKILL.md.
  3. Re-run UAT scenarios 3, 5, 6, 7 from `26-UAT.md`:
     - **S3**: confirm option count = 3 (no duplicate "Other"); native free-text still available.
     - **S5**: confirm body lists `other_repos` basenames verbatim; Cancel re-fires AskUserQuestion (not auto-launch).
     - **S6**: confirm Cancel from fork dialog re-fires AskUserQuestion forced-picker (regression check; S6 already passed but Cancel branch was not exercised).
     - **S7**: confirm refuse banner appears + AskUserQuestion fires; user can pick a different agent.
  4. Record verdicts in `26-UAT.md` as a "Re-run after 26-06" subsection. Mark each scenario PASS / FAIL with one-line evidence.

  **Verification:**
  - All 4 re-run scenarios PASS.
  - If any re-run FAILS, open a new gap-closure plan (26-07) rather than amending 26-06 mid-execute.

  </tasks>

  <security>
  - Edits are docs/markdown only (SKILL.md + REQUIREMENTS.md). No executable surface change. Binary behavior unchanged.
  - Forced-picker re-entry rule does not bypass any `id_validate` guard — free-text resolution still flows through `$LIVE pick-spec --resolve` which validates before any filesystem touch.
  - Cancel handling cannot accidentally launch a different agent than the user picked because the AskUserQuestion fires unconditionally before any `$LIVE start` runs.
  </security>

  <verification>
  **Plan-level acceptance:**
  1. `git diff plugin/spt/skills/live/SKILL.md` shows changes confined to the `## ID Recollection` block (lines 102-153).
  2. `git diff .planning/REQUIREMENTS.md` shows the one-line LIVE-PICK-03 wording fix.
  3. `cargo build --release && cargo test --release -- --test-threads=1` — all tests still pass (no source code changed; this is a sanity check).
  4. Plugin deployed at v1.9.9 via DEPLOY.ps1; marketplace + cache sync confirmed.
  5. UAT scenarios 3, 5, 6, 7 re-run with verdicts recorded in `26-UAT.md`.

  **Phase 26 final closure checklist:**
  - [x] 26-01..26-05 merged + deployed (v1.9.8)
  - [ ] 26-06 SKILL fixes merged
  - [ ] LIVE-PICK-03 wording fix landed
  - [ ] v1.9.9 deployed
  - [ ] All 7 UAT scenarios PASS (4 re-run + 3 already PASS)
  - [ ] `26-SUMMARY.md` written for Phase 26 (rolls up 26-01..26-06)
  - [ ] `26-VERIFICATION.md` re-verified post-fix (status: PASS, gaps_closed populated)
  </verification>

  ## Review Instructions

  Provide:

  1. **Summary** — One-paragraph assessment of Plan 6.
  2. **Strengths** — bullets.
  3. **Concerns** — bullets with severity HIGH/MEDIUM/LOW. Specifically check:
     - Will Tasks 1–4 actually fix the observed bugs? (Skill behavior is driven by SKILL.md prose; assess whether the new prose is unambiguous to a future Claude reader.)
     - Is the "forced picker" rule for Cancel/D9 unambiguous, or could it re-introduce auto-launch in a corner case?
     - Does the D7 body wording change reliably surface `other_repos`? Is there a risk the model still summarizes it away?
     - Are 4 UAT scenarios (3/5/6/7) the right re-run set, or are regressions possible in 1/2/4?
     - Is treating this as a 6th plan inside Phase 26 the right structural choice vs a new sub-phase (e.g., 26.1)?
  4. **Suggestions** — concrete edits to the plan.
  5. **Risk Assessment** — overall risk LOW/MEDIUM/HIGH with justification.

  Be concise and direct. Output markdown.


id: 019e151b-84d3-7850-8a46-1a504b85661a
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e299a2c6fef833dc5da9646393b0342d0f2f3db1
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
agent_nickname: Huygens
agent_role: gsd-planner
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: subagent
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T20-36-25-019e151b-84d3-7850-8a46-1a504b85661a.jsonl

source:
  {"subagent":{"thread_spawn":{"parent_thread_id":"019e14b5-7d98-71b0-a05b-3db412dccc83","depth":1,"agent_path":null,"agent_nickname":"Huygens","agent_role":"gsd-planner"}}}

title:
  Revision task for `$gsd-plan-phase 6`: fix plan-checker blockers in the existing REBNO Phase 6 gap-closure plans. You are not alone in the codebase; do not revert unrelated user or orchestrator edits. Work only on the relevant Phase 6 planning artifacts unless a blocker absolutely requires a small adjacent planning-doc edit.

  Project root: C:\Users\decid\Documents\projects\rebno

  Mode: targeted revision, not full replan.

  Files to read first:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  You own edits to these files for this revision:
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Prior checker verdict: BLOCK. Fix every blocker and the warning below.

  <checker_issues>
  BLOCKER 1: Trace-shaped placeholders will fail `pnpm trace:check`.
  - `06-10-PLAN.md:335` uses invalid tags: `[impl/unit->REQ-CLI-06]` and `[doc->REQ-CDOC-*]`.
  - `06-17-PLAN.md:24` and `06-17-PLAN.md:65` use placeholder `[stage->REQ-ID]`.
  Fix: replace shorthand with valid real tags or prose. Example: `[impl->REQ-CLI-06] [unit->REQ-CLI-06]`; remove wildcard CDOC tag entirely. Avoid any literal tag-shaped placeholder containing `->` unless it exactly matches `[stage->REQ-ID]` with a declared ID.

  BLOCKER 2: `06-17` still has non-cross-platform `grep` commands despite its Windows fix note.
  - `06-17-PLAN.md:274` uses `git lfs ls-files | grep ...`.
  - `06-17-PLAN.md:339` repeats it in the post-update gate.
  - Also inspect nearby grep usage, including `grep -E` and prose asking to paste grep output.
  Fix: replace shell pipelines with cross-platform Node/PowerShell-safe checks, using the Node-based stdout check style already present near `06-17-PLAN.md:250`.

  BLOCKER 3: `06-14` still exceeds scope sanity limits.
  - `06-14-PLAN.md:7` lists 19 modified files across asset pipeline, room converter, server, client renderer, docs, and root scripts.
  - It has 4 implementation tasks at roughly lines 419, 496, 609, 761.
  Fix: do NOT expand Phase 6 back to BNCentral. Keep D-29 reduced scope: 44x40 tile, 20x20 synthetic `mvp-room`, schema discovery pre-task, player-following camera. Either:
    A) split 06-14 into at least asset-bootstrap and room/client-wiring plans if that fits the existing numbering without disrupting 06-15..06-17 too much, OR
    B) add a hard checkpoint/gate between Task 1 and Task 2 so schema/asset output is proven before server/client edits start, and update task wording/success criteria so the plan checker can see the risk is controlled.
  Prefer B unless A can be done cleanly without renumbering the whole gap-closure set.

  WARNING 1: `06-12` misuses `REQ-CDOC-04` for reconnect debug documentation.
  - `06-12-PLAN.md:39` and `06-12-PLAN.md:82` tag a reconnect root-cause doc as CDOC-04, which is the Phase 2 engine-choice ADR requirement.
  Fix: remove `[doc->REQ-CDOC-04]` from 06-12 and keep/use `[doc->REQ-CLI-09] [doc->REQ-CLI-04]` where appropriate. Search the file for additional `REQ-CDOC-04` uses and remove them if they are just reconnect-debug evidence.
  </checker_issues>

  Additional constraints:
  - Preserve D-28..D-38 incorporation from 06-CONTEXT.md.
  - Preserve wave sequencing: Wave 8 (06-10 || 06-11), Wave 9 (06-12 || 06-13), Wave 10 (06-14), Wave 11 (06-15 then 06-16 if serialized by GameScene), Wave 12 (06-17).
  - Do not change implementation code. This revision is plan/docs only.
  - Use `apply_patch` or normal file-edit tools available to you; edit files directly in your workspace.
  - After editing, run targeted checks if feasible:
    - `rg -n "\[impl/unit->|\[doc->REQ-CDOC-\*|\[stage->REQ-ID\]|git lfs ls-files \| grep|grep -E|REQ-CDOC-04" <changed plan files>`
    - `pnpm trace:check:json` if reasonable; if global legacy findings remain, report whether the specific new Phase 6 blocker lines are gone.

  Return final output with marker `## PLANNING COMPLETE`, a changed-files list, and a concise explanation of how each checker issue was addressed.

first_user_message:
  Revision task for `$gsd-plan-phase 6`: fix plan-checker blockers in the existing REBNO Phase 6 gap-closure plans. You are not alone in the codebase; do not revert unrelated user or orchestrator edits. Work only on the relevant Phase 6 planning artifacts unless a blocker absolutely requires a small adjacent planning-doc edit.

  Project root: C:\Users\decid\Documents\projects\rebno

  Mode: targeted revision, not full replan.

  Files to read first:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  You own edits to these files for this revision:
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Prior checker verdict: BLOCK. Fix every blocker and the warning below.

  <checker_issues>
  BLOCKER 1: Trace-shaped placeholders will fail `pnpm trace:check`.
  - `06-10-PLAN.md:335` uses invalid tags: `[impl/unit->REQ-CLI-06]` and `[doc->REQ-CDOC-*]`.
  - `06-17-PLAN.md:24` and `06-17-PLAN.md:65` use placeholder `[stage->REQ-ID]`.
  Fix: replace shorthand with valid real tags or prose. Example: `[impl->REQ-CLI-06] [unit->REQ-CLI-06]`; remove wildcard CDOC tag entirely. Avoid any literal tag-shaped placeholder containing `->` unless it exactly matches `[stage->REQ-ID]` with a declared ID.

  BLOCKER 2: `06-17` still has non-cross-platform `grep` commands despite its Windows fix note.
  - `06-17-PLAN.md:274` uses `git lfs ls-files | grep ...`.
  - `06-17-PLAN.md:339` repeats it in the post-update gate.
  - Also inspect nearby grep usage, including `grep -E` and prose asking to paste grep output.
  Fix: replace shell pipelines with cross-platform Node/PowerShell-safe checks, using the Node-based stdout check style already present near `06-17-PLAN.md:250`.

  BLOCKER 3: `06-14` still exceeds scope sanity limits.
  - `06-14-PLAN.md:7` lists 19 modified files across asset pipeline, room converter, server, client renderer, docs, and root scripts.
  - It has 4 implementation tasks at roughly lines 419, 496, 609, 761.
  Fix: do NOT expand Phase 6 back to BNCentral. Keep D-29 reduced scope: 44x40 tile, 20x20 synthetic `mvp-room`, schema discovery pre-task, player-following camera. Either:
    A) split 06-14 into at least asset-bootstrap and room/client-wiring plans if that fits the existing numbering without disrupting 06-15..06-17 too much, OR
    B) add a hard checkpoint/gate between Task 1 and Task 2 so schema/asset output is proven before server/client edits start, and update task wording/success criteria so the plan checker can see the risk is controlled.
  Prefer B unless A can be done cleanly without renumbering the whole gap-closure set.

  WARNING 1: `06-12` misuses `REQ-CDOC-04` for reconnect debug documentation.
  - `06-12-PLAN.md:39` and `06-12-PLAN.md:82` tag a reconnect root-cause doc as CDOC-04, which is the Phase 2 engine-choice ADR requirement.
  Fix: remove `[doc->REQ-CDOC-04]` from 06-12 and keep/use `[doc->REQ-CLI-09] [doc->REQ-CLI-04]` where appropriate. Search the file for additional `REQ-CDOC-04` uses and remove them if they are just reconnect-debug evidence.
  </checker_issues>

  Additional constraints:
  - Preserve D-28..D-38 incorporation from 06-CONTEXT.md.
  - Preserve wave sequencing: Wave 8 (06-10 || 06-11), Wave 9 (06-12 || 06-13), Wave 10 (06-14), Wave 11 (06-15 then 06-16 if serialized by GameScene), Wave 12 (06-17).
  - Do not change implementation code. This revision is plan/docs only.
  - Use `apply_patch` or normal file-edit tools available to you; edit files directly in your workspace.
  - After editing, run targeted checks if feasible:
    - `rg -n "\[impl/unit->|\[doc->REQ-CDOC-\*|\[stage->REQ-ID\]|git lfs ls-files \| grep|grep -E|REQ-CDOC-04" <changed plan files>`
    - `pnpm trace:check:json` if reasonable; if global legacy findings remain, report whether the specific new Phase 6 blocker lines are gone.

  Return final output with marker `## PLANNING COMPLETE`, a changed-files list, and a concise explanation of how each checker issue was addressed.

preview:
  Revision task for `$gsd-plan-phase 6`: fix plan-checker blockers in the existing REBNO Phase 6 gap-closure plans. You are not alone in the codebase; do not revert unrelated user or orchestrator edits. Work only on the relevant Phase 6 planning artifacts unless a blocker absolutely requires a small adjacent planning-doc edit.

  Project root: C:\Users\decid\Documents\projects\rebno

  Mode: targeted revision, not full replan.

  Files to read first:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  You own edits to these files for this revision:
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Prior checker verdict: BLOCK. Fix every blocker and the warning below.

  <checker_issues>
  BLOCKER 1: Trace-shaped placeholders will fail `pnpm trace:check`.
  - `06-10-PLAN.md:335` uses invalid tags: `[impl/unit->REQ-CLI-06]` and `[doc->REQ-CDOC-*]`.
  - `06-17-PLAN.md:24` and `06-17-PLAN.md:65` use placeholder `[stage->REQ-ID]`.
  Fix: replace shorthand with valid real tags or prose. Example: `[impl->REQ-CLI-06] [unit->REQ-CLI-06]`; remove wildcard CDOC tag entirely. Avoid any literal tag-shaped placeholder containing `->` unless it exactly matches `[stage->REQ-ID]` with a declared ID.

  BLOCKER 2: `06-17` still has non-cross-platform `grep` commands despite its Windows fix note.
  - `06-17-PLAN.md:274` uses `git lfs ls-files | grep ...`.
  - `06-17-PLAN.md:339` repeats it in the post-update gate.
  - Also inspect nearby grep usage, including `grep -E` and prose asking to paste grep output.
  Fix: replace shell pipelines with cross-platform Node/PowerShell-safe checks, using the Node-based stdout check style already present near `06-17-PLAN.md:250`.

  BLOCKER 3: `06-14` still exceeds scope sanity limits.
  - `06-14-PLAN.md:7` lists 19 modified files across asset pipeline, room converter, server, client renderer, docs, and root scripts.
  - It has 4 implementation tasks at roughly lines 419, 496, 609, 761.
  Fix: do NOT expand Phase 6 back to BNCentral. Keep D-29 reduced scope: 44x40 tile, 20x20 synthetic `mvp-room`, schema discovery pre-task, player-following camera. Either:
    A) split 06-14 into at least asset-bootstrap and room/client-wiring plans if that fits the existing numbering without disrupting 06-15..06-17 too much, OR
    B) add a hard checkpoint/gate between Task 1 and Task 2 so schema/asset output is proven before server/client edits start, and update task wording/success criteria so the plan checker can see the risk is controlled.
  Prefer B unless A can be done cleanly without renumbering the whole gap-closure set.

  WARNING 1: `06-12` misuses `REQ-CDOC-04` for reconnect debug documentation.
  - `06-12-PLAN.md:39` and `06-12-PLAN.md:82` tag a reconnect root-cause doc as CDOC-04, which is the Phase 2 engine-choice ADR requirement.
  Fix: remove `[doc->REQ-CDOC-04]` from 06-12 and keep/use `[doc->REQ-CLI-09] [doc->REQ-CLI-04]` where appropriate. Search the file for additional `REQ-CDOC-04` uses and remove them if they are just reconnect-debug evidence.
  </checker_issues>

  Additional constraints:
  - Preserve D-28..D-38 incorporation from 06-CONTEXT.md.
  - Preserve wave sequencing: Wave 8 (06-10 || 06-11), Wave 9 (06-12 || 06-13), Wave 10 (06-14), Wave 11 (06-15 then 06-16 if serialized by GameScene), Wave 12 (06-17).
  - Do not change implementation code. This revision is plan/docs only.
  - Use `apply_patch` or normal file-edit tools available to you; edit files directly in your workspace.
  - After editing, run targeted checks if feasible:
    - `rg -n "\[impl/unit->|\[doc->REQ-CDOC-\*|\[stage->REQ-ID\]|git lfs ls-files \| grep|grep -E|REQ-CDOC-04" <changed plan files>`
    - `pnpm trace:check:json` if reasonable; if global legacy findings remain, report whether the specific new Phase 6 blocker lines are gone.

  Return final output with marker `## PLANNING COMPLETE`, a changed-files list, and a concise explanation of how each checker issue was addressed.


id: 019e1524-d774-76d0-b922-6fd0a2d68841
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e299a2c6fef833dc5da9646393b0342d0f2f3db1
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
agent_nickname: Gibbs
agent_role: gsd-plan-checker
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: subagent
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T20-46-36-019e1524-d774-76d0-b922-6fd0a2d68841.jsonl

source:
  {"subagent":{"thread_spawn":{"parent_thread_id":"019e14b5-7d98-71b0-a05b-3db412dccc83","depth":1,"agent_path":null,"agent_nickname":"Gibbs","agent_role":"gsd-plan-checker"}}}

title:
  Re-check REBNO Phase 6 gap-closure plans after targeted planner revision.

  Project root: C:\Users\decid\Documents\projects\rebno

  Load:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Context: Previous checker returned BLOCK for:
  1. Invalid trace-shaped placeholders in 06-10 and 06-17.
  2. Non-cross-platform grep commands in 06-17.
  3. 06-14 scope too large without a hard checkpoint/split.
  4. Warning: 06-12 misused REQ-CDOC-04 for reconnect debug docs.

  Planner reports it fixed these by:
  - Valid tags/prose in 06-10/06-17.
  - Removed REQ-CDOC-04 reconnect-debug tagging from 06-12.
  - Replaced grep/LFS checks in 06-17 with cross-platform Node checks.
  - Added 06-14-G0 and 06-14-G1 gates and reduced 06-14 to 3 tasks while preserving D-29 reduced mvp-room scope.

  Please verify all previous blockers are fixed and no new blockers were introduced. Treat generic `verify.plan-structure` warning "Wave > 1 but depends_on is empty" as acceptable if the plan is a legitimate parallel wave root according to the Wave table; otherwise flag it with exact fix.

  Return concise verdict: `## VERIFICATION PASSED` or `## ISSUES FOUND`, then severity-ordered findings with exact file/line refs. Do not edit files.

first_user_message:
  Re-check REBNO Phase 6 gap-closure plans after targeted planner revision.

  Project root: C:\Users\decid\Documents\projects\rebno

  Load:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Context: Previous checker returned BLOCK for:
  1. Invalid trace-shaped placeholders in 06-10 and 06-17.
  2. Non-cross-platform grep commands in 06-17.
  3. 06-14 scope too large without a hard checkpoint/split.
  4. Warning: 06-12 misused REQ-CDOC-04 for reconnect debug docs.

  Planner reports it fixed these by:
  - Valid tags/prose in 06-10/06-17.
  - Removed REQ-CDOC-04 reconnect-debug tagging from 06-12.
  - Replaced grep/LFS checks in 06-17 with cross-platform Node checks.
  - Added 06-14-G0 and 06-14-G1 gates and reduced 06-14 to 3 tasks while preserving D-29 reduced mvp-room scope.

  Please verify all previous blockers are fixed and no new blockers were introduced. Treat generic `verify.plan-structure` warning "Wave > 1 but depends_on is empty" as acceptable if the plan is a legitimate parallel wave root according to the Wave table; otherwise flag it with exact fix.

  Return concise verdict: `## VERIFICATION PASSED` or `## ISSUES FOUND`, then severity-ordered findings with exact file/line refs. Do not edit files.

preview:
  Re-check REBNO Phase 6 gap-closure plans after targeted planner revision.

  Project root: C:\Users\decid\Documents\projects\rebno

  Load:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-11-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-12-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-14-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-17-PLAN.md

  Context: Previous checker returned BLOCK for:
  1. Invalid trace-shaped placeholders in 06-10 and 06-17.
  2. Non-cross-platform grep commands in 06-17.
  3. 06-14 scope too large without a hard checkpoint/split.
  4. Warning: 06-12 misused REQ-CDOC-04 for reconnect debug docs.

  Planner reports it fixed these by:
  - Valid tags/prose in 06-10/06-17.
  - Removed REQ-CDOC-04 reconnect-debug tagging from 06-12.
  - Replaced grep/LFS checks in 06-17 with cross-platform Node checks.
  - Added 06-14-G0 and 06-14-G1 gates and reduced 06-14 to 3 tasks while preserving D-29 reduced mvp-room scope.

  Please verify all previous blockers are fixed and no new blockers were introduced. Treat generic `verify.plan-structure` warning "Wave > 1 but depends_on is empty" as acceptable if the plan is a legitimate parallel wave root according to the Wave table; otherwise flag it with exact fix.

  Return concise verdict: `## VERIFICATION PASSED` or `## ISSUES FOUND`, then severity-ordered findings with exact file/line refs. Do not edit files.


id: 019e1528-91e5-7363-8117-0f691d99356e
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e299a2c6fef833dc5da9646393b0342d0f2f3db1
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
agent_nickname: Erdos
agent_role: gsd-planner
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: subagent
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T20-50-40-019e1528-91e5-7363-8117-0f691d99356e.jsonl

source:
  {"subagent":{"thread_spawn":{"parent_thread_id":"019e14b5-7d98-71b0-a05b-3db412dccc83","depth":1,"agent_path":null,"agent_nickname":"Erdos","agent_role":"gsd-planner"}}}

title:
  Revision iteration 2 for `$gsd-plan-phase 6`: fix remaining plan-checker BLOCKER only. You are not alone in the codebase; do not revert unrelated edits. Do not replan. Edit only these files unless you find the exact same invalid trace shorthand elsewhere in Phase 6 plans:
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md

  Checker issue to fix:
  - 06-13-PLAN.md line ~77 and ~355: replace `[impl/unit->REQ-CLI-04]` with `[impl->REQ-CLI-04] [unit->REQ-CLI-04]`.
  - 06-15-PLAN.md line ~107 and ~450: replace `[impl/int->REQ-CLI-09]` with `[impl->REQ-CLI-09] [int->REQ-CLI-09]`.
  - 06-16-PLAN.md line ~85, ~472, and ~483: replace `[impl/unit->REQ-CLI-04]` with `[impl->REQ-CLI-04] [unit->REQ-CLI-04]`.

  After edits, run:
  `rg -n "\[(impl/unit|impl/int)->" .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md`

  Return marker `## PLANNING COMPLETE`, changed files, and check result. Keep this surgical.

first_user_message:
  Revision iteration 2 for `$gsd-plan-phase 6`: fix remaining plan-checker BLOCKER only. You are not alone in the codebase; do not revert unrelated edits. Do not replan. Edit only these files unless you find the exact same invalid trace shorthand elsewhere in Phase 6 plans:
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md

  Checker issue to fix:
  - 06-13-PLAN.md line ~77 and ~355: replace `[impl/unit->REQ-CLI-04]` with `[impl->REQ-CLI-04] [unit->REQ-CLI-04]`.
  - 06-15-PLAN.md line ~107 and ~450: replace `[impl/int->REQ-CLI-09]` with `[impl->REQ-CLI-09] [int->REQ-CLI-09]`.
  - 06-16-PLAN.md line ~85, ~472, and ~483: replace `[impl/unit->REQ-CLI-04]` with `[impl->REQ-CLI-04] [unit->REQ-CLI-04]`.

  After edits, run:
  `rg -n "\[(impl/unit|impl/int)->" .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md`

  Return marker `## PLANNING COMPLETE`, changed files, and check result. Keep this surgical.

preview:
  Revision iteration 2 for `$gsd-plan-phase 6`: fix remaining plan-checker BLOCKER only. You are not alone in the codebase; do not revert unrelated edits. Do not replan. Edit only these files unless you find the exact same invalid trace shorthand elsewhere in Phase 6 plans:
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md

  Checker issue to fix:
  - 06-13-PLAN.md line ~77 and ~355: replace `[impl/unit->REQ-CLI-04]` with `[impl->REQ-CLI-04] [unit->REQ-CLI-04]`.
  - 06-15-PLAN.md line ~107 and ~450: replace `[impl/int->REQ-CLI-09]` with `[impl->REQ-CLI-09] [int->REQ-CLI-09]`.
  - 06-16-PLAN.md line ~85, ~472, and ~483: replace `[impl/unit->REQ-CLI-04]` with `[impl->REQ-CLI-04] [unit->REQ-CLI-04]`.

  After edits, run:
  `rg -n "\[(impl/unit|impl/int)->" .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-13-PLAN.md .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-15-PLAN.md .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-16-PLAN.md`

  Return marker `## PLANNING COMPLETE`, changed files, and check result. Keep this surgical.


id: 019e1529-c6ef-7041-954d-e9b760330f43
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e299a2c6fef833dc5da9646393b0342d0f2f3db1
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
agent_nickname: Epicurus
agent_role: gsd-plan-checker
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: subagent
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T20-51-59-019e1529-c6ef-7041-954d-e9b760330f43.jsonl

source:
  {"subagent":{"thread_spawn":{"parent_thread_id":"019e14b5-7d98-71b0-a05b-3db412dccc83","depth":1,"agent_path":null,"agent_nickname":"Epicurus","agent_role":"gsd-plan-checker"}}}

title:
  Final re-check for REBNO Phase 6 gap-closure plans after revision iteration 2.

  Project root: C:\Users\decid\Documents\projects\rebno

  Load:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - all `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md` through `06-17-PLAN.md`

  Previous checker found one remaining BLOCKER:
  - invalid shorthand tags in 06-13, 06-15, 06-16 (`[impl/unit->...]`, `[impl/int->...]`).

  Planner revision 2 reports it split those into valid separate tags. Local orchestrator checks:
  - `rg -n --glob '06-1*-PLAN.md' "\[(impl/unit|impl/int)->|\[doc->REQ-CDOC-\*|\[stage->REQ-ID\]|git lfs ls-files \| grep|grep -E" .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone` returned no matches.
  - frontmatter validates for 06-13/06-15/06-16.
  - plan structure validates for 06-13/06-15/06-16; generic wave-root warning remains only for 06-13, which is expected if it is a Wave 9 parallel root.

  Please verify the complete gap-closure plan set now. Return exactly one of:
  - `## VERIFICATION PASSED` if no blockers/warnings remain that should stop Wave 8 execution.
  - `## ISSUES FOUND` with severity-ordered exact file/line findings if anything still blocks or materially risks execution.

  Do not edit files.

first_user_message:
  Final re-check for REBNO Phase 6 gap-closure plans after revision iteration 2.

  Project root: C:\Users\decid\Documents\projects\rebno

  Load:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - all `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md` through `06-17-PLAN.md`

  Previous checker found one remaining BLOCKER:
  - invalid shorthand tags in 06-13, 06-15, 06-16 (`[impl/unit->...]`, `[impl/int->...]`).

  Planner revision 2 reports it split those into valid separate tags. Local orchestrator checks:
  - `rg -n --glob '06-1*-PLAN.md' "\[(impl/unit|impl/int)->|\[doc->REQ-CDOC-\*|\[stage->REQ-ID\]|git lfs ls-files \| grep|grep -E" .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone` returned no matches.
  - frontmatter validates for 06-13/06-15/06-16.
  - plan structure validates for 06-13/06-15/06-16; generic wave-root warning remains only for 06-13, which is expected if it is a Wave 9 parallel root.

  Please verify the complete gap-closure plan set now. Return exactly one of:
  - `## VERIFICATION PASSED` if no blockers/warnings remain that should stop Wave 8 execution.
  - `## ISSUES FOUND` with severity-ordered exact file/line findings if anything still blocks or materially risks execution.

  Do not edit files.

preview:
  Final re-check for REBNO Phase 6 gap-closure plans after revision iteration 2.

  Project root: C:\Users\decid\Documents\projects\rebno

  Load:
  - AGENTS.md
  - .planning/STATE.md
  - .planning/PROJECT.md
  - .planning/ROADMAP.md
  - .planning/REQUIREMENTS.md
  - traceable-reqs.toml
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-CONTEXT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-HUMAN-UAT.md
  - .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-REVIEWS.md
  - all `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/06-10-PLAN.md` through `06-17-PLAN.md`

  Previous checker found one remaining BLOCKER:
  - invalid shorthand tags in 06-13, 06-15, 06-16 (`[impl/unit->...]`, `[impl/int->...]`).

  Planner revision 2 reports it split those into valid separate tags. Local orchestrator checks:
  - `rg -n --glob '06-1*-PLAN.md' "\[(impl/unit|impl/int)->|\[doc->REQ-CDOC-\*|\[stage->REQ-ID\]|git lfs ls-files \| grep|grep -E" .planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone` returned no matches.
  - frontmatter validates for 06-13/06-15/06-16.
  - plan structure validates for 06-13/06-15/06-16; generic wave-root warning remains only for 06-13, which is expected if it is a Wave 9 parallel root.

  Please verify the complete gap-closure plan set now. Return exactly one of:
  - `## VERIFICATION PASSED` if no blockers/warnings remain that should stop Wave 8 execution.
  - `## ISSUES FOUND` with severity-ordered exact file/line findings if anything still blocks or materially risks execution.

  Do not edit files.


id: 019e1572-22b6-7af1-b551-c67ec6c416a8
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
title: $gsd-update
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e299a2c6fef833dc5da9646393b0342d0f2f3db1
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
first_user_message: $gsd-update
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
preview: $gsd-update
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\10\rollout-2026-05-10T22-11-02-019e1572-22b6-7af1-b551-c67ec6c416a8.jsonl


id: 019e258e-5284-77a3-a275-8e0dea555481
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
title: $gsd-resume-work
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: 3651864171b53e2b1c3f23149e2e0b6663ee78f2
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
first_user_message: $gsd-resume-work
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
preview: $gsd-resume-work
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\14\rollout-2026-05-14T01-15-44-019e258e-5284-77a3-a275-8e0dea555481.jsonl


id: 019e25a2-a7d1-7582-a7dd-ff8a599c67fc
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
title: $gsd-execute-phase 06.3
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: 4f10aa4aed16621da4074605837f0e37a1668225
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
first_user_message: $gsd-execute-phase 06.3
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
preview: $gsd-execute-phase 06.3
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\14\rollout-2026-05-14T01-37-57-019e25a2-a7d1-7582-a7dd-ff8a599c67fc.jsonl


id: 019e2fd4-62d4-7ee3-a8e8-dce63b26ab3a
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: 9e7adb4ffa93da5dc8d9a35a389540491ffc7341
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\16\rollout-2026-05-16T01-08-28-019e2fd4-62d4-7ee3-a8e8-dce63b26ab3a.jsonl

title:
  $gsd-fast extract the icon from "BN Online Client 5-8.exe" and assign it as the favicon for the published client in this project (both for staging and prod)

first_user_message:
  $gsd-fast extract the icon from "BN Online Client 5-8.exe" and assign it as the favicon for the published client in this project (both for staging and prod)

preview:
  $gsd-fast extract the icon from "BN Online Client 5-8.exe" and assign it as the favicon for the published client in this project (both for staging and prod)


id: 019e2fda-0fd4-7492-8549-a0f37925b0ca
source: cli
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"danger-full-access"}
approval_mode: never
git_sha: e20bb99fd327850143d38d4b90cd1c267f98eeb8
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
thread_source: user
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\16\rollout-2026-05-16T01-14-40-019e2fda-0fd4-7492-8549-a0f37925b0ca.jsonl

title:
  $gsd-debug using "C:\Users\decid\Documents\projects\rebno\TEMP_RESUME.md". delete the file after reading it.

first_user_message:
  $gsd-debug using "C:\Users\decid\Documents\projects\rebno\TEMP_RESUME.md". delete the file after reading it.

preview:
  $gsd-debug using "C:\Users\decid\Documents\projects\rebno\TEMP_RESUME.md". delete the file after reading it.


id: 019e34fa-7e57-7e00-ac0b-0762fdf4d192
source: exec
model_provider: openai
cwd: C:\Users\decid\Documents\projects\rebno
sandbox_policy: {"type":"read-only"}
approval_mode: never
git_sha: 8728dcec59a65f5d13b2eda8602347a8b33f0c47
git_branch: main
git_origin_url: https://github.com/SaberMage/rebno.git
cli_version: 0.130.0
memory_mode: enabled
model: gpt-5.5
reasoning_effort: high
rollout_path:
  C:\Users\decid\.codex\sessions\2026\05\17\rollout-2026-05-17T01-08-12-019e34fa-7e57-7e00-ac0b-0762fdf4d192.jsonl

title:
  # Cross-AI Plan Review Request

  You are reviewing implementation plans for a software project phase.
  Provide structured feedback on plan quality, completeness, and risks.

  ## Project Context
  # REBNO — Rebuild BN Online

  ## What This Is

  REBNO is a ground-up modernization of *BN Online*, a late-1990s/early-2000s multiplayer game shipped as a GameMaker 5.3a `.gmd` project. The work extracts the original client and server source from `BN Online Client 5-8.gmd` and `BN Online Master 5-4.gmd`, documents engine + server features, then rebuilds both halves on a modern web stack (TypeScript everywhere, Node server on Fly.io, Phaser 3 *or* PixiJS client running in Chrome) at full feature parity with a modernized UX.

  ## Core Value

  **Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.** Everything else (graphics polish, full feature parity, scaling) flows from that working multiplayer baseline.

  ## Requirements

  ### Validated

  <!-- Brownfield: existing artifacts in repo. Code/wiki present, but nothing new shipped yet. -->

  - ✓ GameMaker 5.3a reverse-engineering wiki (17 docs in `decomp/wiki/`) — existing
  - ✓ Legacy archive of all `.gmd` revisions, server source, Unity remake attempt, Maya assets, audio — existing
  - ✓ Codebase map (`.planning/codebase/`, 7 docs) — existing
  - ✓ Stage 1 (Extraction) complete — Validated in Phase 1: extraction (2026-05-03)
    - `tools/extract-gmd/` TS Node CLI ports `LateralGM GmFileReader` semantics; 157/157 tests
    - `extracted/client-5-8/` (8307 files) + `extracted/server-5-4/` (1144 files) committed; reproducibility byte-identical
    - 13 wire-format bugs uncovered + documented in `decomp/wiki/03-gmd-format.md` errata
    - Sprite/background image data carried as opaque `imageBytes` (Option A pivot — BMP→PNG decode deferred to Phase 6/7 asset-pipeline; EXT-04 acceptance relaxed accordingly)

  ### Active

  #### Stage 1 — Extraction (DONE — Phase 1, 2026-05-03)

  #### Stage 2 — Client analysis (DONE — Phase 2, 2026-05-04)

  #### Stage 3 — Server analysis (DONE — Phase 3, 2026-05-05; status: human_needed — Linux CI verify-phase-3 deferred per HUMAN-UAT.md)

  #### Stage 4 — Server rebuild (NEXT)
  - [ ] Reimplement server in Node + TypeScript with binary WebSocket transport
  - [ ] Refactor as code is ported: identify and eliminate bugs from the original
  - [ ] Migrate original user accounts to a modern hash (bcrypt/argon2) — preserve usernames, force password reset on first login OR transparent re-hash on first successful auth
  - [ ] Authoritative server model — clients never trust each other for game state
  - [ ] Room/level layouts stored server-side and transmitted to client over the wire on room entry, integrity-verified; client bundle ships no static layout data (SRV-13)
  - [ ] Moving-platform live positions deterministic via `packages/game-logic` `step()` and broadcast authoritatively to all clients in the same state-diff envelope as player positions (SRV-14)

  #### Stage 5 — Deploy
  - [ ] Deploy new server to Fly.io with persistent volume + Litestream backup (or Postgres)
  - [ ] CI/CD pipeline: push to main → deploy
  - [ ] Health checks, structured logging, basic ops dashboard

  #### Stage 6 — Client rebuild (MVP)
  - [ ] Web client (Phaser 3 or PixiJS, TypeScript, Vite) running in Chrome
  - [ ] Modernized UX (HiDPI, responsive, keyboard + gamepad input)
  - [ ] **MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server**

  #### Stage 7 — Full parity
  - [ ] All client features from Stage 2 documentation reimplemented
  - [ ] All server features from Stage 3 documentation reimplemented
  - [ ] Original-game test cases pass against new client + server

  ### Out of Scope

  - **Mobile / native ports** — Chrome-first; no iOS, Android, Steam binaries in v1
  - **Engine-level emulation of GameMaker 5.3a** — we reimplement game *logic*, not the GM5 runtime
  - **Re-running the original Windows binary** — preservation belongs in `legacy/`, not the rebuild
  - **Server-side anti-cheat beyond authoritative model** — no behavioral detection in v1
  - **Modding API / user-generated content** — out of scope until parity ships
  - **3D / VR / new gameplay features** — faithful rebuild first, then iterate
  - **Public marketing / asset rights clearance** — license + distribution decision deferred until rebuild is functional (see Key Decisions)

  ## Context

  - **Repo layout** (see `.planning/codebase/STRUCTURE.md`): `decomp/wiki/` holds RE knowledge, `legacy/` holds all original artifacts (audio, maya-project, open-source-release, servers, source-archive, unity-project). Two prior remake attempts exist (`legacy/unity-project`, `legacy/open-source-release`); both can inform the new rebuild but are not the authoritative source — the `.gmd` files are.
  - **Source-of-truth files**: `legacy/open-source-release/BN Online Client 5-8.gmd` (client) and `legacy/open-source-release/BN Online Master 5-4.gmd` (server). Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only.
  - **Engine constraints to overcome** (from wiki): GM5.3a is Delphi 5 / Win32, modern decompilers don't read it, network code is 39dll-only (TCP, no native sockets), assets are ZLIB-compressed inside a monolithic XOR-obfuscated payload appended to the runner stub. Extraction pipeline is documented in `decomp/wiki/15-extraction-pipeline.md`.
  - **Proprietary formats**: `.bno` / `.bnb` save data is bespoke — only readable by walking the extracted GML's `file_bin_*` call order, not via external tools.
  - **Bit-rot / IP risks** flagged in `.planning/codebase/CONCERNS.md` (229 lines) — read before committing scope.

  ## Constraints

  - **Tech stack — server**: Node + TypeScript. Mature ecosystem, best WS tooling (`ws`, `uWebSockets.js`), strong type story for shared client/server packet definitions.
  - **Tech stack — client**: TypeScript + Phaser 3 (preferred) or PixiJS (fallback). Decision deferred to end of Stage 2 once feature surface is known. Must run in Google Chrome, no plugins.
  - **Tech stack — transport**: WebSocket binary frames. Authoritative server, clients send intent, server emits state.
  - **Tech stack — persistence**: TBD in Stage 3 (likely SQLite + Litestream for <50 player target; Postgres if data model demands relational queries).

  ## Phase 06.7: Network model — client-trust + server illegal-position fall trigger
  ### Roadmap Section
  ### Phase 06.7: Network model shift to client-trust + server illegal-position fall trigger (INSERTED)\r\n\r\n**Goal**: Flip the player-movement trust model so client owns its position and server stores client-reported position as authoritative state for movement only. Reconciler becomes a no-op for the self-player (still applied to remote players where server state IS authoritative). Server retains authority over chat origin, inventory, scores, combat math, room transitions. As an anti-cheat backstop, server runs tick-by-tick `isPositionLegal(player.x, player.y, room.walkable_grid)`; if illegal for > N ticks (~250-500 ms grace), server triggers a fall event = forced animation + position reset to last-known-legal or designated respawn. The fall mechanism is the second deliverable (Phase B in the seed) and is what makes client-trust safe at the 50 CCU target. Update CLAUDE.md Hard Rule 1 to clarify movement-specific trust split.\r\n\r\n**Motivation**: Operator UAT 2026-05-16 surfaced choppy movement on staging — root cause is reconciler snap-back from client-prediction-vs-server-truth drift (e.g. diagonal stop produces ~10 px horizontal shift). Core REBNO value depends on predictable movement; original BNO was peer-to-peer 39dll where clients trusted each other's positions, so the design space precedent matches.\r\n\r\n**Depends on:** Phase 06.6\r\n**Requirements**: REQ-SRV-03, REQ-SRV-14, REQ-CLI-04, REQ-CLI-08\r\n**Plans:** 4 plans\n\nPlans:\n- [ ] 06.7-01-PLAN.md — Wave 1 — Protocol surface: cPositionUpdateSchema + cSetSpriteOverrideSchema + packAnimState + PlayerState fields + PROTOCOL_VERSION 3→4 (REQ-SRV-03, REQ-CLI-04, REQ-CLI-08)\n- [ ] 06.7-02-PLAN.md — Wave 2 — Server: position_update + set_sprite_override handlers + tickLoop platforms-only + protocol-v4 handshake integ (REQ-SRV-03, REQ-SRV-14, REQ-CLI-08)\n- [ ] 06.7-03-PLAN.md — Wave 2 — Client: PositionDispatcher + reconciler self-noop (D-04) + protocol_version=4 handshake (REQ-CLI-04, REQ-CLI-08)\n- [ ] 06.7-04-PLAN.md — Wave 3 — CLAUDE.md Hard Rule 1 carve-out (D-13) + operator HUMAN-UAT + STATE/ROADMAP wrap (REQ-CLI-08, REQ-SRV-03)\r\n\r\n**Risks to weigh during discuss/plan:**\r\n- Cheaters can teleport visually (mitigated by fall-reset, but visible to other players briefly until grace expires).\r\n- Two players claiming same position → server arbitration needed for inter-player collisions.\r\n- Position-update spam → rate-limit at game tick rate (30 Hz per Extracted Constants).\r\n- Reconciler refactor + tests need a feature-flag transition path; do NOT bundle this with any in-flight phase.

  ### Requirements Addressed
  REQ-SRV-03, REQ-SRV-14, REQ-CLI-04, REQ-CLI-08 (+ doctrine anchor REQ-SRV-04)

  ### User Decisions (CONTEXT.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger - Context

  **Gathered:** 2026-05-17
  **Status:** Ready for planning

  <domain>
  ## Phase Boundary

  Flip the **self-player movement trust model** from hybrid client-prediction + server-authoritative-reconciler to **client-authoritative position**. Client sends absolute `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}` at 30 Hz; server stores the client-reported state as authoritative and broadcasts to remote players. Reconciler becomes a **no-op for the self-player** (the remote-player path is unchanged — server-state IS authoritative for those).

  Server retains full authority over **non-movement state**: chat origin, inventory, room transitions, persistence. (REBNO has no scores or combat in scope.)

  Motivating bugs (operator UAT 2026-05-16, staging):
  - ~10 px horizontal shift on diagonal stop → root cause is reconciler snap from client-vs-server position drift in `apps/client/src/prediction/reconciler.ts`.
  - Occasional movement hitching that appears packet-loss correlated → current sync-strict reconciler assumes a perfect network; absolute-position wire + client-trust naturally absorbs single-packet drops.

  **Anti-cheat (Phase B in the seed) is OUT OF SCOPE.** Server-side legality check + fall reset is deferred to backlog (candidate Phase 06.8) — to be triggered only when cheating becomes observable. Pre-launch staging is a trusted operator pool at 50 CCU target, so the abuse window is acceptable.

  </domain>

  <decisions>
  ## Implementation Decisions

  ### Scope

  - **D-01:** Phase A only — flip self-player to client-trust + reconciler no-op. Phase B (fall reset + legality check) → backlog (candidate Phase 06.8).
  - **D-02:** Motivating bugs explicitly in scope: (a) ~10 px diagonal-stop drift on staging; (b) dropped-packet hitching tolerance. Current model assumes perfect network — fix that.
  - **D-03:** Inter-player collision arbitration is **out of scope** (not currently observed; theoretical risk only).
  - **D-04:** D-57b and D-58b anti-revert regression tests are **explicitly retired** as an acceptance criterion of this phase. They guard reconciler behavior that is being removed for the self-player. They are replaced by a regression test asserting **the self-player sprite position is never written by `ReconcileEngine.onServerSnapshot`** (positive guard against future revert).

  ### Wire shape + cadence

  - **D-05:** Position-update wire shape (client → server, every tick): `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}`. **Absolute position** (not deltas) — single dropped packet is recovered cleanly by the next.
  - **D-06:** Cadence = **30 Hz steady**, regardless of motion. Idle player sends `{x, y, vx=0, vy=0, facing, anim_state=STAND_*, ...}` 30 times/sec. Matches Minecraft's `PlayerPosition` cadence model and keeps the server's authoritative store always-fresh for remote-player broadcasts.
  - **D-07:** **Int16 quantization** on `x, y` — client snap-rounds to integer pixels before sending. Matches the BNO snap-round model already in `packages/game-logic/src/step.ts:117-122`. Removes float-comparison footguns. Halves packet size vs float.
  - **D-08:** `anim_state` is a 1-byte enum carried in **every position_update** (NOT derivable from kinematics — moving platforms, conveyor platforms, and hexport all produce velocity-without-running states; future spectator/watching pose too). Enum covers 8 directions × {stand, run} + reserved slots for special poses. The existing `SpriteStateMachine` in `packages/game-logic/src/sprite-state-machine.ts` continues to drive client-side animation selection; its current output is what gets shipped.
  - **D-09:** Special non-derivable sprite states (HexportIn / HexportOut / TeleIn / TeleOut / ncol overlays / jokershell / watching) ship via a separate **`set_sprite_override`** event-only intent. Rare event triggers — no place in the 30 Hz hot path. Mirrors the `set_facing` precedent shipped in 06.4 round-3 (D-58c).
  - **D-10:** `facing` field stays in position_update (NOT folded into `anim_state`). Facing = "where the player is pointing" intent (D-58c set_facing semantics); `anim_state` = "what pose to render right now". Different semantics, both needed.

  ### Anti-cheat

  - **D-11:** **Zero server-side anti-cheat in 06.7.** Server trusts the client-reported position fully. Rationale: 50 CCU target, pre-launch private staging, trusted operator pool. Speed-cap + walkable-grid sanity + fall reset → all deferred to future 06.8 (or whenever cheating becomes observable on real-user traffic).

  ### Migration + doctrine

  - **D-12:** **Hard cut on staging.** No feature flag. Staging is the only deployment target pre-launch; operator UAT validates the new model in place. If staging regresses, rollback = `/gsd-undo` on phase commits.
  - **D-13:** **CLAUDE.md Hard Rule 1 — narrow movement carve-out.** Add one paragraph: "Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE — server stores client-reported state. Non-movement state (chat origin, inventory, room transitions, persistence) remains server-authoritative." Drop scores/combat from the example list — REBNO has no plans for those. Restructuring Rule 1 into a tabular trust matrix is **out of scope** (broader rewrite for a future phase if doctrine grows more complex).
  - **D-14:** **Rollback strategy = `/gsd-undo` phase commits.** No dead-code branches kept around.

  ### Research directive (for /gsd-plan-phase 06.7)

  - **D-15:** **Minecraft Java Edition is the canonical reference model.** Researcher (gsd-phase-researcher) must produce a comparison table in RESEARCH.md scoring at minimum:
    - **Minecraft Java Edition** (`PlayerPosition` packet, server-side `Player#move` validation, force-teleport correction)
    - **Quake / Tribes lineage** (client-prediction + server-reconcile — what REBNO is moving AWAY from)
    - **Current REBNO model** (hybrid prediction + 22 px reconciler snap/lerp)
    
    ...against the constraints: 50 CCU target, predictable-movement (core REBNO value), dropped-packet tolerance, single Fly.io machine, Colyseus 0.17 transport.

  - **D-16:** Dropped-packet tolerance is a first-class research dimension. Researcher should investigate: how often is a missed `position_update` survivable for the remote-player path? Do we need server-side extrapolation between packets, or does 30 Hz + absolute-position + Colyseus transport reliability make it irrelevant?

  ### Claude's Discretion

  - Exact `anim_state` enum members + numeric byte values — pick during planning based on existing `SpriteStateMachine` states. Add a binary-format note to `packages/protocol` matching int16 quantization.
  - Whether to keep the `seq` echo / `last_input_seq` round-trip on the position_update path or drop it (current `cInputSchema` uses seq for ordering — likely keep for monotonic ordering + idempotent retries).
  - Server-side `step()` retention strategy — server's authoritative step still drives platforms + future Phase B fall checks. Whether to keep it running on a separate tick for self-player platform-effects (conveyor application) vs. treating client's reported velocity as the truth.
  - Whether to send remote-player broadcast diffs at full 30 Hz or downsampled (server-side fan-out optimization — researcher input).

  ### Folded Todos
  None — the 2026-05-16 split-playwright-smoke todo (score 0.9) keyword-matched on "staging/uat/operator" but is a CI workflow refactor, not a network-model concern. Reviewed-only; see Deferred Ideas.

  </decisions>

  <canonical_refs>
  ## Canonical References

  **Downstream agents MUST read these before planning or implementing.**

  ### Network-model precedent (external — researcher to fetch via WebSearch / WebFetch)
  - **Minecraft Java Edition `PlayerPosition` / `PlayerPositionAndLook` packet semantics** — official protocol docs (wiki.vg or equivalent). The canonical implementation pattern of client-trust + server force-correct. Researcher to produce a comparison table per D-15.
  - **Minecraft `Player#move` server-side validation** — `maxMoveDistanceSquared`, terrain check, force-teleport response. Reference even though REBNO Phase B is deferred — informs the future 06.8 plan.

  ### Current REBNO model (read these to understand what is being changed)
  - `apps/client/src/prediction/reconciler.ts` — `ReconcileEngine.onServerSnapshot` (the 22 px threshold + lerp/snap logic that becomes a no-op for self-player). 123 LOC.
  - `apps/client/src/prediction/predictor.ts` — `PredictionEngine` (self-player local prediction + ack/replay; need to determine whether this stays for input-driven local responsiveness even with client-trust).
  - `apps/client/src/prediction/input-dispatcher.ts` — current `c2s.input` axes wire path (D-09 keydown/keyup events + D-58c `set_facing` precedent for client-authoritative state).
  - `apps/server/src/RebnoRoom.ts` §621-622 — server-side spawn position assignment + tick loop (1389 LOC; pay attention to player.x / player.y writes — those become assignments from the position_update message, not from `step()` output).
  - `apps/server/src/onMessageHandlers.ts` — zod-validated wire intent handlers. New `position_update` + `set_sprite_override` validators belong here.
  - `packages/game-logic/src/step.ts` — pure deterministic step. Becomes server-side platform-effects-only for self-player; remote-player broadcast pass-through. Animation derivation **NOT possible** from x/y/vx/vy per D-08.
  - `packages/game-logic/src/sprite-state-machine.ts` — SpriteStateMachine that produces `anim_state`; ship its current output verbatim on the wire.
  - `packages/game-logic/src/collision.ts` — collision primitives (still needed for client-side prediction-of-self for input responsiveness; server walkable-grid check is deferred to 06.8).
  - `packages/protocol/src/intents.ts` — `cInputSchema` (the precedent shape for the new `position_update` schema).

  ### Project doctrine to update
  - `CLAUDE.md` §"Hard Rules" Rule 1 — narrow movement carve-out per D-13. **This update is part of the 06.7 deliverable**, not a side effect.
  - `.planning/REQUIREMENTS.md` — REQ-SRV-03 / REQ-SRV-14 / REQ-CLI-04 / REQ-CLI-08 references (no requirement change needed — the trust-model swap is a refactor of how those reqs are satisfied, not a redefinition).

  ### Prior-phase context to carry forward
  - `.planning/phases/06.4-cycle-5-gap-closure-d-51b-d-55b-d-55c-d-57b-d-58b-d-60-d-61-/` — D-58c set_facing precedent (client-authoritative wire intent for a state field previously derived server-side). The new `position_update` follows the same playbook.
  - `.planning/phases/06.6-uat-accounts-integer-viewport-scaling-chat-region-clamp-righ/06.6-CONTEXT.md` — prior phase context style + decision-numbering convention.

  ### Phase seed
  - `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-SEED.md` — promoted from the 2026-05-16 todo; full problem statement and Phase A / Phase B framing.

  </canonical_refs>

  <code_context>
  ## Existing Code Insights

  ### Reusable Assets
  - **`SpriteStateMachine` (`packages/game-logic/src/sprite-state-machine.ts`)** — already produces the animation pose enum client-side. Wire its output as `anim_state` on every position_update; no new state machine needed.
  - **`set_facing` intent (06.4 D-58c)** — exact precedent for client-authoritative state field shipped on a wire intent. Copy the validator + handler + broadcast pattern.
  - **`cInputSchema.strict()` zod validator (`packages/protocol/src/intents.ts`)** — the security pattern: zod validates everything on the wire before the server consumes it. Apply identically to `position_update` (sanity-validates int16 range, enum membership, monotonic seq) even though no anti-cheat envelope ships in 06.7.
  - **`HEARTBEAT_INTERVAL_MS = 15_000` in `input-dispatcher.ts`** — heartbeat-keepalive pattern. Position_update at 30 Hz IS the heartbeat — no separate keepalive needed.

  ### Established Patterns
  - **Deterministic `step()` in `packages/game-logic`** — pure function, no I/O, runs identically in browser and Node. Stays useful for client-side input-responsiveness prediction (self-player) and for platform/conveyor effect application. Remote-player position is no longer derived from `step()` — it's stored from the wire.
  - **Threat-model invariant T-06.1.02-01 (step.ts:151-158 comment)** — server never trusts client-fabricated walkable_grid. Compatible with 06.7: server still doesn't consume client grid; it just stores client-reported position. Document the new trust line clearly in CLAUDE.md per D-13.
  - **REQ-SRV-14 deterministic broadcast envelope** — the existing state-diff envelope from Phase 4 stays. `position_update` from a client mutates the authoritative `PlayerState`; broadcast envelope unchanged.
  - **`globalThis.__rebno.lastReconcile*` diagnostic ring buffer** (`reconciler.ts:73-92`) — operator-debugging surface. Remove cleanly during 06.7; if a self-reconciler regression slips in, this is the canary that resurfaces.

  ### Integration Points
  - **`onMessageHandlers.ts`** — add `position_update` handler (validate via zod, write into PlayerState, broadcast in next tick) + `set_sprite_override` handler.
  - **`RebnoRoom.ts` tick loop** — server `step()` no longer computes self-player position from axes; instead it consumes the most-recent client-reported `{x, y, vx, vy, facing, anim_state}` and broadcasts. Platform/conveyor application TBD per Claude's Discretion above.
  - **`apps/client/src/net/colyseus-client.ts`** — replace the axes-only dispatcher path with the position_update dispatcher. Existing `input-dispatcher.ts` keydown/keyup events still drive **local** SpriteStateMachine; the dispatcher swap is "what we send", not "how we read input".
  - **`apps/client/src/prediction/reconciler.ts`** — self-player branch becomes no-op (return early when `snap.account_id === local.account_id`). Remote-player branch unchanged. Document the split clearly in the file header.
  - **`apps/client/src/prediction/predictor.ts`** — likely stays for client-side local input-to-position prediction (instant input responsiveness without round-trip). Researcher to confirm during planning.

  </code_context>

  <specifics>
  ## Specific Ideas

  - **Minecraft Java Edition is the explicit precedent.** User asked specifically how Minecraft implements its semi-server-authoritative movement and confirmed it as a research reference. The pattern (client-trust + server validation as separate concerns) is the structural blueprint for 06.7 (Phase A) + 06.8 (future Phase B).
  - **Absolute position on every packet (not deltas).** User reasoned about packet-loss tolerance from first principles: the current model "effectively assumes a perfect network connection", which it shouldn't. Mirroring Minecraft's absolute-position wire makes single-packet drops invisible.
  - **Animation cannot be derived from kinematics.** User caught and corrected the initial idea of server-deriving `anim_state` from `(x, y, vx, vy)`. Counter-examples: moving platforms (velocity-without-running), conveyor platforms (velocity-without-running), hexport (motion-with-special-pose). Ship `anim_state` on the wire explicitly.
  - **No scores or combat in REBNO scope.** User clarified during Hard Rule 1 discussion. Rule 1 carve-out language must reflect the actual REBNO domain (chat, inventory, room transitions, persistence), not generic MMO doctrine.
  - **Trust-fully on staging.** User explicitly rejected speed-cap-as-cheap-first-line and logging-only options for 06.7. Pre-launch + 50 CCU + private = no anti-cheat envelope justifies the implementation cost yet.

  </specifics>

  <deferred>
  ## Deferred Ideas

  - **Phase B — server illegal-position detection + fall reset.** Tick-by-tick `isPositionLegal(x, y, walkable_grid)` check; if illegal > N ticks (~250-500 ms grace), trigger fall event = forced animation + position reset to last-known-legal-position or designated respawn. Anti-cheat backstop. **Candidate Phase 06.8** — trigger when cheating becomes observable on real-user traffic OR before public launch, whichever comes first. Needs BNO legacy fall-spec extraction (drift falls, void falls, respawn target) as research input.
  - **Inter-player collision arbitration.** Two clients claiming overlapping positions — server picks one canonical (latest-update-wins / lower-account-id / etc.) OR client-side visual nudge offset. Defer until observed in operator UAT.
  - **Speed-cap server-side validation** (`max_delta_per_tick = RUN_SPEED * 1.5`). Cheap first-line cheat detection. Backlog for 06.8 alongside fall reset.
  - **Walkable-grid sanity check server-side.** Server verifies client position is on a walkable tile. Requires server to compute walkable_grid (currently client-only per T-06.1.02-01). Backlog for 06.8.
  - **CLAUDE.md trust-doctrine table restructure.** Replace Rule 1 prose with a per-domain authority matrix. Defer until doctrine grows complex enough to justify the rewrite — Rule 1 narrow carve-out (D-13) is the right move now.
  - **Server-side `step()` future role.** Whether server keeps running `step()` for platform/conveyor effect application (server applies platform vx/vy to player position) vs. trusting the client's reported position to already include platform-carry. Planner + researcher input needed.

  ### Reviewed Todos (not folded)
  - `2026-05-16-split-playwright-smoke-into-3rd-workflow-job.md` (area: ci, score 0.9) — Operator-suggested 06.5 follow-up to split `deploy-staging.yml` `full-path` into separate deploy + smoke jobs so `gh run watch` can distinguish "deploy succeeded but smoke failed" from "deploy failed". **Out of scope for 06.7** — different domain (CI workflow refactor, not network model). Keyword-match was on "staging/uat/operator", not on substance. Stays in `.planning/todos/pending/` for a future CI-polish phase.

  </deferred>

  ---

  *Phase: 06.7-network-model-client-trust-fall-trigger*
  *Context gathered: 2026-05-17*

  ### Research Findings (RESEARCH.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger — Research

  **Researched:** 2026-05-17
  **Domain:** Real-time multiplayer netcode trust model (client-authoritative position with absolute-position state replication)
  **Confidence:** HIGH for stack/architecture; MEDIUM for downsampling and broadcast-cadence recommendations (validated against Minecraft analog, but specific Colyseus 0.17 fan-out telemetry will only come from staging UAT)

  <user_constraints>
  ## User Constraints (from CONTEXT.md)

  ### Locked Decisions

  **Scope**
  - **D-01:** Phase A only — flip self-player to client-trust + reconciler no-op. Phase B (fall reset + legality check) → backlog (candidate Phase 06.8).
  - **D-02:** Motivating bugs explicitly in scope: (a) ~10 px diagonal-stop drift on staging; (b) dropped-packet hitching tolerance. Current model assumes perfect network — fix that.
  - **D-03:** Inter-player collision arbitration is **out of scope** (not currently observed; theoretical risk only).
  - **D-04:** D-57b and D-58b anti-revert regression tests are **explicitly retired** as an acceptance criterion of this phase. They guard reconciler behavior that is being removed for the self-player. Replaced by a regression test asserting **the self-player sprite position is never written by `ReconcileEngine.onServerSnapshot`** (positive guard against future revert).

  **Wire shape + cadence**
  - **D-05:** Position-update wire shape (client → server, every tick): `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}`. **Absolute position** (not deltas) — single dropped packet recovered cleanly by the next.
  - **D-06:** Cadence = **30 Hz steady**, regardless of motion. Idle player sends `{x, y, vx=0, vy=0, facing, anim_state=STAND_*, ...}` 30 times/sec. Matches Minecraft's `PlayerPosition` cadence model and keeps the server's authoritative store always-fresh for remote-player broadcasts.
  - **D-07:** **Int16 quantization** on `x, y` — client snap-rounds to integer pixels before sending. Matches the BNO snap-round model already in `packages/game-logic/src/step.ts:117-122`. Removes float-comparison footguns. Halves packet size vs float.
  - **D-08:** `anim_state` is a 1-byte enum carried in **every position_update** (NOT derivable from kinematics — moving platforms, conveyor platforms, hexport all produce velocity-without-running states). Enum covers 8 directions × {stand, run} + reserved slots. The existing `SpriteStateMachine` in `packages/game-logic/src/sprite-state-machine.ts` continues to drive client-side animation selection; its current output is what gets shipped.
  - **D-09:** Special non-derivable sprite states (HexportIn/Out, TeleIn/Out, ncol overlays, jokershell, watching) ship via a separate **`set_sprite_override`** event-only intent. Mirrors `set_facing` precedent shipped in 06.4 round-3 (D-58c).
  - **D-10:** `facing` stays in position_update (NOT folded into `anim_state`). Facing = "where the player is pointing"; `anim_state` = "what pose to render right now". Different semantics, both needed.

  **Anti-cheat**
  - **D-11:** **Zero server-side anti-cheat in 06.7.** Server trusts client-reported position fully. 50 CCU, pre-launch private staging, trusted operators — speed-cap, walkable-grid sanity, and fall reset all deferred to 06.8.

  **Migration + doctrine**
  - **D-12:** **Hard cut on staging.** No feature flag. Rollback = `/gsd-undo` on phase commits.
  - **D-13:** **CLAUDE.md Hard Rule 1 — narrow movement carve-out.** One paragraph: "Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE — server stores client-reported state. Non-movement state (chat origin, inventory, room transitions, persistence) remains server-authoritative." Drop scores/combat from the example list — REBNO has none.
  - **D-14:** **Rollback strategy = `/gsd-undo` phase commits.** No dead-code branches.

  **Research directive**
  - **D-15:** Researcher must produce a comparison table scoring Minecraft Java Edition vs. Quake/Tribes lineage vs. current REBNO model against constraints: 50 CCU, predictable-movement, dropped-packet tolerance, single Fly.io machine, Colyseus 0.17.
  - **D-16:** Dropped-packet tolerance is a first-class research dimension.

  ### Claude's Discretion
  - Exact `anim_state` enum members + numeric byte values — pick during planning based on existing `SpriteStateMachine` states. Add a binary-format note to `packages/protocol`.
  - Whether to keep `seq`/`last_input_seq` echo on the position_update path or drop it.
  - Server-side `step()` retention — whether to keep it running on a separate tick for self-player platform-effects (conveyor application) vs. trusting client's reported velocity.
  - Whether to send remote-player broadcast diffs at full 30 Hz or downsampled (server fan-out optimization).

  ### Deferred Ideas (OUT OF SCOPE for 06.7)
  - **Phase B — server illegal-position detection + fall reset** (→ candidate 06.8).
  - **Inter-player collision arbitration** (defer until observed).
  - **Speed-cap server-side validation** (→ 06.8).
  - **Walkable-grid sanity check server-side** (→ 06.8).
  - **CLAUDE.md trust-doctrine table restructure** (Rule 1 narrow carve-out only for now).
  - **Server-side `step()` future role** (planner + researcher input — addressed below).
  </user_constraints>

  <phase_requirements>
  ## Phase Requirements

  | ID | Title (from `traceable-reqs.toml`) | Research Support |
  |----|-----------------------------------|------------------|
  | REQ-SRV-03 | `apps/server` runs Node 22 + Colyseus 0.17 with one Room class for the MVP slice | Server-side: new `position_update` + `set_sprite_override` handlers added to `onMessageHandlers.ts`; tick-loop in `RebnoRoom.ts` modified to skip `step()` for self-player movement (covered in "Architecture Patterns" below) |
  | REQ-SRV-14 | Deterministic moving-platform sync via game-logic step; clients interpolate, never extrapolate | Platforms remain server-authoritative — they are world state, NOT player movement. The carve-out is narrow: only player x/y/vx/vy/facing/anim_state flip to client-trust. Platforms stay derived from `step()` (covered in "Pitfalls" + "Architecture Patterns") |
  | REQ-CLI-04 | Client-side prediction + entity interpolation + server reconciliation | Reconciler becomes no-op for self-player; entity interpolation/extrapolation for REMOTE players UNCHANGED (server state IS authoritative for those). The req remains satisfied — its implementation morphs (covered in "Don't Hand-Roll" + "Architecture Patterns") |
  | REQ-CLI-08 | MVP GATE: two players join, move, and chat over deployed server | Two-player movement smoothness regression — the existing 06.4 e2e integration tests against staging confirm REQ-CLI-08 still holds post-flip. Specifically: `apps/client/src/__test__/colyseus-client.test.ts` + server `event-driven-input.integ.test.ts` (covered in "Validation Architecture") |

  **Required stages** for each: `doc` + `impl` mandatory for all; `unit` for REQ-SRV-03/SRV-14/CLI-04; `int` for REQ-SRV-03/SRV-14/CLI-08.
  </phase_requirements>

  ## Summary

  The phase flips player-movement trust from hybrid client-prediction + server-authoritative-reconciler to **client-authoritative absolute-position streaming**, matching Minecraft Java Edition's `Set Player Position` model. Client streams `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}` at 30 Hz; server stores the most-recent payload and broadcasts to remote players. The reconciler becomes a no-op for the self-player; the remote-player extrapolation path is preserved because the server-stored value IS authoritative for those peers.

  Three findings drive the recommendation:

  1. **Minecraft's model is the industry-standard precedent.** Minecraft sends absolute-position `Set Player Position` packets at 20 Hz, server stores them as authoritative state, and uses force-teleport `Synchronize Player Position` only as a correction backstop (the latter being REBNO's deferred 06.8). The "trusted store + corrective override" pattern is exactly Phase A + Phase B.
  2. **30 Hz absolute position over TCP/Colyseus is robust to single-packet loss by construction.** Each packet is fully self-describing; missing tick N's update just means tick N+1's update lands one frame later. No state divergence is possible because there's no delta to lose context for. Server-side extrapolation between packets is unnecessary at 30 Hz with Colyseus's TCP ordering guarantee.
  3. **Custom server-side broadcast fan-out optimization (downsampling) is premature.** At 50 CCU × 30 Hz × ~20 B payload, broadcast bandwidth is ~37 kbps down per client — well within Fly.io shared-cpu-1x. Ship at full 30 Hz; revisit if profiling shows fan-out cost.

  **Primary recommendation:** Implement the wire shape exactly as D-05 specifies, ship at 30 Hz steady, keep `seq` for ordering/idempotent-retries (existing `cInputSchema` precedent), drop the server-side `step()` call for self-player position entirely (it stops being authoritative for x/y), retain `step()` for platforms (REQ-SRV-14 unchanged), broadcast at full 30 Hz patch rate, use a 1-byte packed `anim_state` enum with bits 0-2 = octant + bit 3 = running + bits 4-7 reserved.

  ## Architectural Responsibility Map

  | Capability | Primary Tier | Secondary Tier | Rationale |
  |------------|-------------|----------------|-----------|
  | Self-player position (x, y) | Browser/Client | API/Backend (storage only) | D-05 — client owns its position; server is a write-through store |
  | Self-player velocity (vx, vy) | Browser/Client | API/Backend (storage only) | Derived from intent on the client; server stores for broadcast only |
  | Self-player anim_state | Browser/Client | API/Backend (storage only) | D-08 — non-derivable from kinematics; client SpriteStateMachine output is canonical |
  | Self-player facing | Browser/Client | API/Backend (storage only) | D-10 — D-58c set_facing precedent extended to every-tick streaming |
  | Remote-player rendering | Browser/Client | — | Client consumes server-broadcast state and runs existing extrapolation cap (250 ms per ADR 0007) |
  | Remote-player state-of-record | API/Backend | — | Server is the canonical store of every player's last-reported {x, y, vx, vy, facing, anim_state}; clients receive via Colyseus state-diff |
  | Platform position | API/Backend | — | REQ-SRV-14 unchanged: deterministic, derived from `step()` on the server, NOT carved out |
  | Chat origin / sender identity | API/Backend | — | Hard Rule 1 unchanged — server-tagged from `auth.account_id` per `onMessageHandlers.ts:182-188` |
  | Inventory / persistence | API/Backend | Database/Storage | Hard Rule 1 unchanged |
  | Room transitions | API/Backend | — | Hard Rule 1 unchanged — `room_join` handler is the authority |
  | Spawn position | API/Backend | — | `RebnoRoom.findHomePortal()` assigns; client adopts on join. Only the FIRST authoritative position; after spawn, client owns it |
  | Anti-cheat enforcement | (none in 06.7) | — | Deferred to 06.8 |

  ## Standard Stack

  ### Core (already pinned — no changes)
  | Library | Version | Purpose | Why Standard |
  |---------|---------|---------|--------------|
  | `colyseus` | 0.17.10 | Authoritative-server framework, room/state-diff/WS transport | `[VERIFIED: apps/server/package.json:32]` — already in use; no version change |
  | `@colyseus/sdk` | 0.17.42 | Client-side Colyseus | `[VERIFIED: apps/client/package.json:21]` — already in use |
  | `@colyseus/schema` | 4.0.23 | Schema-delta encoder for the `PlayerState`/`RoomState` MapSchemas | `[VERIFIED: apps/server/package.json:21]` — already in use |
  | `phaser` | 3.90.0 | Client-side renderer | `[VERIFIED: apps/client/package.json:25]` — already in use |
  | `zod` | 3.23.x | Wire validator at the onMessage boundary | `[VERIFIED: apps/server/package.json:45]` — already in use; new `cPositionUpdateSchema` adds here |
  | `msgpackr` | 1.11.10 | S2C event codec | `[VERIFIED: packages/protocol/src/events.ts:7]` — already in use; no new S2C event needed |

  ### Supporting — no new libraries

  The phase introduces **zero new dependencies**. Everything is a refactor of how the existing libraries are wired.

  **Version verification:** Pins above are the **current** versions in `package.json` and were verified against the files in this session. No dependency upgrades are part of the phase.

  ### Alternatives Considered (and rejected per CONTEXT)
  | Instead of | Could Use | Why Rejected |
  |------------|-----------|--------------|
  | Colyseus state-diff (PlayerState mutation) for broadcast | A new msgpackr s2c `position_broadcast` event | The Colyseus state-diff path already broadcasts `PlayerState` mutations at `setPatchRate(TICK_MS)`. Re-using it costs zero new wire shapes. Adding a parallel msgpackr broadcast would create two sources of truth and split the test surface. |
  | Float64 x/y on the wire | Float32 or fixed-point Q16.16 | D-07 locks int16. Float64 is 4× larger than int16; pixel-quantized positions don't need sub-integer precision because step.ts already snap-rounds before write. |
  | Binary packed position_update | msgpackr-encoded object | The existing `cInputSchema` lives as a JSON-shaped object (Colyseus message channel does its own packing). Binary packing would gain ~10-20 B per frame at the cost of a parallel codec test surface. Defer unless 50 CCU CPU profile shows the JSON path as a hot spot. |

  ## Architecture Patterns

  ### System Architecture Diagram

  ```
  Self-player flow (NEW in 06.7):
    Keyboard input  →  InputDispatcher (client)
                         ↓
                       PredictionEngine.predictTick(layout)   [LOCAL FEEL — kept]
                         ↓ (writes localState.x/y/vx/vy)
                       Phaser sprite render               [INSTANT RESPONSE]
                         ↓
                       PositionDispatcher.send(position_update)  [NEW: every 30 Hz tick]
                         │
                         │     wire: {x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}
                         │     binary: int16+int16+int16+int16+u8+u8+u32+u32 = 18 B payload
                         ↓
                [ Colyseus WS / TCP — reliable, ordered ]
                         ↓
                       RebnoRoom.onMessage('position_update')
                         ↓
                       cPositionUpdateSchema.safeParse()  [zod strict — D-04]
                         ↓ (pass) write into PlayerState.{x, y, vx, vy, facing, anim_state}
                         │
                         │
                Tick loop:
                RebnoRoom.setSimulationInterval(TICK_MS=50)
                         ↓
                       step() runs for PLATFORMS ONLY (REQ-SRV-14)
                         │   no longer reads/writes self-player position
                         ↓
                       Colyseus setPatchRate(TICK_MS=50)
                         ↓
                [ broadcast PlayerState diffs to all clients ]
                         ↓
                Remote-player flow (UNCHANGED):
                       PlayerState.onChange (per-field)
                         ↓
                       PlayerRenderer.setRemotePosition (extrapolation cap 250 ms)
                         ↓
                       deriveFrameWithAuthoritativeFacing (06.4 D-58c — kept)
                         ↓
                       remote sprite render

    Self-reconciler path (REMOVED):
                       ReconcileEngine.onServerSnapshot
                         ↓ for self-player: NO-OP (early return when snap.account_id === local.account_id)
                         ↓ for remote-player: NOT INVOKED (remote path uses PlayerRenderer directly, NOT ReconcileEngine)
                         │   → Effectively dead code for self; can be removed entirely once 06.8 confirms no future need
  ```

  Key data-flow change vs. the current model:
  - **Before:** keypress → c2s.input (axes) → server.step() → state diff → reconciler (self & remote)
  - **After:** keypress → c2s.position_update (absolute x/y/vx/vy/facing/anim_state from client predictor) → state diff (passthrough) → renderer (remote only)

  ### Recommended Project Structure

  No structural changes. All file paths exist; this is a refactor:

  ```
  packages/protocol/src/
  ├── intents.ts              # ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema, AnimState enum
  ├── events.ts               # unchanged (force_reset event reserved for 06.8 fall trigger)
  └── version.ts              # BUMP PROTOCOL_VERSION 3 → 4 (wire shape change)

  apps/server/src/
  ├── onMessageHandlers.ts    # ADD: position_update handler + set_sprite_override handler
  └── RebnoRoom.ts            # MODIFY: tick loop — step() no longer writes self-player x/y/vx/vy/facing

  apps/client/src/
  ├── prediction/
  │   ├── predictor.ts        # KEEP — local-feel prediction stays for input responsiveness
  │   ├── reconciler.ts       # MODIFY: no-op for self-player; remote unchanged. Remove globalThis.__rebno.lastReconcile* writes for self
  │   └── input-dispatcher.ts # ADD: per-tick PositionDispatcher branch alongside the keydown/keyup InputDispatcher
  └── net/
      └── colyseus-client.ts  # MODIFY: bindHandlers wires position_update dispatch on local player tick

  CLAUDE.md                   # MODIFY: Hard Rule 1 narrow carve-out per D-13
  ```

  ### Pattern 1: Absolute-position client-trust streaming (Minecraft-canonical)

  **What:** Client computes its position locally and ships absolute coordinates to the server every tick. Server stores the latest payload as authoritative state and broadcasts to remote peers. Server does not compute, validate, or correct positions.

  **When to use:** Pre-launch private games where the trust boundary can include the player; games where predictable feel matters more than anti-cheat; small player counts.

  **Example (server side):**
  ```typescript
  // Source: pattern derived from packages/protocol/src/intents.ts:45-59 + apps/server/src/onMessageHandlers.ts:104-136
  // [CITED: Minecraft "Set Player Position" packet — minecraft.wiki/w/Java_Edition_protocol/Packets]
  export const cPositionUpdateSchema = z.object({
    type: z.literal('position_update'),
    x: z.number().int().min(-32768).max(32767),
    y: z.number().int().min(-32768).max(32767),
    vx: z.number().int().min(-32768).max(32767),
    vy: z.number().int().min(-32768).max(32767),
    facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    anim_state: z.number().int().min(0).max(255),  // packed enum (see below)
    seq: z.number().int().min(0),
    monotonic_at_ms: z.number().nonnegative(),
  }).strict();

  ctx.room.onMessage('position_update', (client, raw) => {
    const auth = ctx.getAuth(client.sessionId);
    if (!auth) return;
    if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;
    const parsed = cPositionUpdateSchema.safeParse(raw);
    if (!parsed.success) return;
    const player = ctx.room.state.players.get(client.sessionId);
    if (!player) return;
    player.x = parsed.data.x;
    player.y = parsed.data.y;
    player.vx = parsed.data.vx;
    player.vy = parsed.data.vy;
    player.facing = parsed.data.facing;
    player.anim_state = parsed.data.anim_state;
    player.last_input_seq = parsed.data.seq;  // keep — see "Pattern 4"
  });
  ```

  ### Pattern 2: Server-side step() retains platform authority (REQ-SRV-14 preserved)

  **What:** The server still runs `step()` every tick for platforms (and any future world state). Players are skipped entirely in the player loop within `step()`.

  **When to use:** Whenever the carve-out is partial — only one entity type (player position) flips to client-trust while everything else stays server-authoritative.

  **Example:**
  ```typescript
  // Source: derived from packages/game-logic/src/step.ts:65-235
  // Modification: skip the player-input-application block; players carry the values written
  // by the position_update handler. Platforms still advance per p.x += p.vx * dt_ms.

  export function step(state, inputs, dt_ms) {
    // 1. Advance platforms (UNCHANGED — REQ-SRV-14)
    const newPlatforms = new Map();
    for (const [id, p] of state.platforms) {
      newPlatforms.set(id, { x: p.x + p.vx * dt_ms, y: p.y + p.vy * dt_ms, vx: p.vx, vy: p.vy, cycle_phase: p.cycle_phase + dt_ms });
    }
    // 2. Players: pass-through. The position_update handler already wrote authoritative values.
    //    REMOVED: input-axis decoding, diagonal normalization, per-axis sub-pixel collision.
    return { rev: state.rev + 1, rng_state: state.rng_state, players: state.players, platforms: newPlatforms, room_layout: state.room_layout };
  }
  ```

  **Important nuance:** the existing `step()` signature is preserved; client-side prediction in `predictor.ts` still calls `step()` with the client's local walkable_grid, so client-side wall-slide collision is preserved. The change is purely server-side: the server no longer derives player movement from input axes — but `step()` still exists and still gets called for platforms.

  ### Pattern 3: Self-reconciler no-op (positive guard)

  **What:** When a server snapshot arrives for the local player, the reconciler returns early. The renderer keeps using the predictor's local state, never written by the snapshot path.

  **When to use:** When the server is no longer the authoritative source for self-player position (the client IS).

  **Example:**
  ```typescript
  // Source: derived from apps/client/src/prediction/reconciler.ts:52
  // Replace onServerSnapshot's self-player branch with an early return:

  onServerSnapshot(snap: ServerSnapshot, isSelfPlayer: boolean): void {
    if (isSelfPlayer) {
      // [impl->REQ-CLI-04] D-04 acceptance criterion: self-player sprite is
      // NEVER written by the snapshot path. Self-player position comes from
      // PredictionEngine.predictTick + InputDispatcher only.
      return;
    }
    // Remote-player path unchanged — but note that remote rendering does NOT
    // currently go through ReconcileEngine at all (it goes through
    // PlayerRenderer.setRemotePosition). So this branch is effectively dead
    // code today; we retain the signature for symmetry and the (unused) call
    // site, but the entire ReconcileEngine class is a candidate for removal
    // after staging UAT confirms no remote-player consumer.
  }
  ```

  ### Pattern 4: Keep `seq` echo on position_update (recommended)

  **What:** Client increments a per-message sequence number on every position_update; server echoes the last-received seq in `PlayerState.last_input_seq`.

  **When to use:** Whenever you want ordering/staleness detection and idempotent retries on reconnect.

  **Why keep it:** Three concrete benefits, all cheap:
  1. **Staleness detection:** Server can ignore an out-of-order position_update (`parsed.data.seq <= player.last_input_seq`). Defends against the rare TCP reorder-after-reconnect case.
  2. **Reconnect coherency:** After SRV-06's 10 s grace reconnect, the client may have local seq=N; sending one position_update advances the server's last_input_seq cleanly.
  3. **Existing infrastructure reuse:** `PlayerState.last_input_seq` is already a `@colyseus/schema` field; no protocol change to keep it.

  **Cost:** 4 bytes per frame (u32). Trivial.

  ### Anti-Patterns to Avoid

  - **Don't recompute position server-side after the position_update arrives.** The whole point of the flip is that the client owns it. Running `step()` then overwriting with the client value would just be wasted CPU AND introduce a race window where the rendered/broadcast value flickers between the two.
  - **Don't extrapolate remote players on the server.** Colyseus state-diff fans out per `setPatchRate`; the existing client-side extrapolator (apps/client extrapolation cap 250 ms per ADR 0007) already handles brief gaps. Server extrapolation duplicates this logic on the wrong side of the boundary.
  - **Don't drop the heartbeat semantics.** The existing 15 s `HEARTBEAT_INTERVAL_MS` in `input-dispatcher.ts` survives reconnect-grace. With position_update at 30 Hz, the heartbeat is implicit (any frame proves liveness) — but keep the explicit `c2s.heartbeat` handler for the case where the player is in a menu / chat-input-focused state and is NOT streaming position_updates (movement is paused per `pauseMovement()`).
  - **Don't ship `anim_state` as a string.** D-08 calls for 1-byte enum; sending `"NaviRunDR_003"` per frame at 30 Hz is 14+ bytes of bandwidth waste vs. 1 byte for the pose enum + cycle phase derived client-side from velocity.

  ## Don't Hand-Roll

  | Problem | Don't Build | Use Instead | Why |
  |---------|-------------|-------------|-----|
  | Position broadcasting | A new msgpackr `s2c.position_broadcast` event | The existing Colyseus `PlayerState` MapSchema delta channel | Already pinned at `setPatchRate(TICK_MS)`; already test-covered; adding a parallel msgpackr broadcast doubles the test surface and creates two sources of truth |
  | Position ordering | A custom sequence-window state machine | Existing `last_input_seq` field on `PlayerState` + the seq from D-05 | The seq field exists, is wire-tested, and the comparator is one line: `if (seq <= player.last_input_seq) return;` |
  | Self-player visual smoothing | A new "client-side smoothing buffer" between predict and render | Existing `PredictionEngine.predictTick` + Phaser sprite.setPosition each tick | Local feel is already instant; the smoothing buffer of yesteryear is an artifact of round-trip latency, which the client-trust model eliminates |
  | Server-side anti-cheat envelope | A speed-cap, walkable-grid, illegal-position machine | NOTHING in 06.7 | D-11 LOCKS this. Backlog for 06.8 |
  | Remote-player extrapolation | A new extrapolation algorithm | Existing `PlayerRenderer.setRemotePosition` + extrapolation cap per ADR 0007 + `axis_x_held`/`axis_y_held` on PlayerState | Already shipped; the new `vx`/`vy` fields from position_update populate the same `PlayerState.vx`/`vy` the renderer already consumes |
  | Packet-loss handling | A custom retransmit / sequence-gap detector | Nothing — Colyseus runs over TCP/WS which is reliable + ordered | TCP guarantees delivery and order. The only "loss" possible is the connection drop, which is already handled by SRV-06's 10 s grace |

  **Key insight:** Every component the phase needs already exists; the work is rewiring trust direction, not building new mechanisms.

  ## Comparison Table (D-15)

  Scored against constraints: **50 CCU**, **predictable-movement** (core REBNO value), **dropped-packet tolerance**, **single Fly.io machine**, **Colyseus 0.17 transport**.

  Scale: ✅ aligns / ⚠️ partial / ❌ misaligned.

  | Constraint | Minecraft Java Edition | Quake / Tribes lineage | Current REBNO (pre-06.7) | REBNO post-06.7 (recommended) |
  |------------|------------------------|------------------------|--------------------------|-------------------------------|
  | **Trust model** | Client-trusted position; server stores; "Synchronize Player Position" corrective backstop `[CITED: minecraft.wiki/w/Java_Edition_protocol/Packets]` | Server-authoritative simulation; client predicts, server reconciles `[CITED: fabiensanglard.net/quake3/network.php]` | Server-authoritative simulation; client predicts, server-reconciler-snaps when >22 px divergence `[VERIFIED: reconciler.ts:21]` | Client-trusted (matches Minecraft model). No fall-trigger correction in 06.7 |
  | **Wire shape** | Absolute position: `{X, Y, Z, on_ground}` + variants for rotation. Doubles for coordinates (3×8=24 B per packet) `[CITED: minecraft.wiki]` | Compact input commands + delta-compressed state from old baseline `[CITED: ra.is/unlagged/network.html]` | Axes-only `{seq, axes:{x,y}, buttons_down/up, monotonic_at_ms}` — event-driven, NOT every-tick `[VERIFIED: cInputSchema in intents.ts]` | Absolute position int16: 18 B payload (2+2+2+2+1+1+4+4) |
  | **Cadence** | 20 Hz (server tick rate); client sends position every tick under normal play `[CITED: minecraft.wiki/w/Tick — "20 ticks per second"]` | ~20-30 Hz client snapshots; server snapshots variable | Event-driven on keydown/keyup transitions + 15 s heartbeat — NO per-tick send `[VERIFIED: input-dispatcher.ts:26]` | **30 Hz steady, regardless of motion** (D-06) — matches BNO native tick rate; the heartbeat IS implicit |
  | **Dropped-packet tolerance** | ✅ Excellent — every packet is self-describing; missed packet recovered by next | ⚠️ Quake3 delta-compresses against arbitrary old snapshot; loss recovery is per-snapshot ack `[CITED: fabiensanglard.net/quake3/network.php]` | ❌ Poor — the held-axes map persists state across ticks, but a dropped keyup leaves player walking until heartbeat re-affirms (15 s). Operator UAT 2026-05-16 confirmed "hitching" `[VERIFIED: SEED §Problem]` | ✅ Excellent — absolute-position-per-tick means single packet loss is one missed frame at 30 Hz = 33 ms — invisible. TCP order + Colyseus reliability means packet loss is the connection drop, which SRV-06 grace already handles |
  | **Predictable movement** | ✅ Client owns its position — no rubber-banding under normal play | ⚠️ Reconciler snap if prediction diverges enough; visible in fast-paced FPS | ❌ Failing today — reconciler snap on diagonal-stop produces ~10 px shift `[VERIFIED: SEED §Problem]` | ✅ No reconciler-snap path for self-player; visual position = predictor output, which never reverts |
  | **50 CCU bandwidth** | N/A directly comparable (different scale per server) | Per-server typical 32 CCU; bandwidth scales linearly | Low — event-driven keeps c2s sparse | ~37 kbps down per client (50 peers × 30 Hz × ~25 B per peer); ~15 Mbps aggregate at 50×50. Fly.io shared-cpu-1x handles trivially |
  | **Single Fly.io machine** | ✅ N/A — Minecraft uses one process per server | ✅ Quake3 uses one machine | ✅ Already deployed | ✅ No change — broadcast is still one fan-out per `setPatchRate` tick |
  | **Colyseus 0.17 transport** | N/A (Minecraft is TCP + custom protocol) | N/A (Quake3 is UDP) | ✅ Already configured `[VERIFIED: RebnoRoom.ts:177-178]` | ✅ Same channel (`onMessage('position_update')`) + same state-diff broadcast — zero transport changes |
  | **Anti-cheat posture** | Strong — server force-corrects via `Synchronize Player Position` clientbound | Strong — full simulation authority | Strong — full server authority over x/y | **Zero (D-11)** — accepted risk on pre-launch trusted operator pool |
  | **Implementation cost** | Reference model only — not directly implementable | Heavyweight — full simulation symmetry required | Already shipped | Modest refactor — new handler + handler-side validator + reconciler-no-op + tick-loop trim |

  **Score summary:**
  - **Minecraft model** is the closest aligned reference for REBNO's pre-launch posture: client owns position, server stores it. ✅ for everything that matters.
  - **Quake lineage** is what REBNO is moving AWAY from — it's overkill for a 50 CCU walking-chat game and the reconciler snap is the SOURCE of operator-reported pain.
  - **Current REBNO** scores ❌ on predictable-movement and packet-loss tolerance — both motivating bugs in SEED.

  ## Dropped-Packet Tolerance Analysis (D-16)

  **Question:** how often is a missed `position_update` survivable for the remote-player path? Do we need server-side extrapolation between packets?

  **Finding (HIGH confidence):** No server-side extrapolation needed. Three converging reasons:

  ### 1. Colyseus runs over TCP — there is no "missed packet" in the UDP sense

  `[VERIFIED: WebSearch — Colyseus FAQ + docs.colyseus.io/server/transport/ws]` Colyseus's default transport is WebSocket (TCP). TCP guarantees:
  - **In-order delivery** — frames arrive in send order
  - **Reliable delivery** — frames are retransmitted on loss at the transport layer
  - **No reordering** — application sees a strictly ordered stream

  The only "packet loss" possible from the application's perspective is the connection itself dropping, which already triggers Colyseus's `onLeave` → SRV-06 10 s reconnect grace. During that grace window the player's position is frozen at last-known; this is the correct behavior and matches operator UAT expectations (a dropped player visually pauses, doesn't extrapolate into walls).

  ### 2. 30 Hz absolute position is self-healing by construction

  `[VERIFIED: REQ-SRV-05 in REQUIREMENTS.md + accumulator.ts:8]` Each position_update is fully self-describing — no deltas, no baseline references, no acks required. If frame N is somehow delayed (TCP retransmit blip), frame N+1 lands 33 ms later (at 30 Hz) and contains the same data type — the latest absolute position. The visual effect on the remote viewer: one frame's pause, then jump to current. At 33 ms intervals this is invisible to human perception.

  Compare with Quake 3's delta-compressed snapshots `[CITED: ra.is/unlagged/network.html]`, which can stall ALL subsequent decodes until the baseline arrives — REBNO's model is more robust here.

  ### 3. The remote-extrapolator already exists for the 30 ms gap case

  `[VERIFIED: packages/protocol/src/state.ts:31-32 + ADR 0007 cited there]` `PlayerState.axis_x_held` and `axis_y_held` are broadcast every tick today. The client-side extrapolator uses these to smooth between snapshots (250 ms cap per ADR 0007). With position_update at 30 Hz, gap between consumed snapshots is normally 33 ms; the extrapolator only kicks in when a snapshot is genuinely delayed. The 250 ms cap means even a worst-case TCP-retransmit-spike (~150 ms) is bounded.

  **The new position_update fields populate the same PlayerState slots.** `vx` and `vy` from D-05 land in `PlayerState.vx`/`vy` and the existing extrapolator can incorporate them directly. No new client logic required.

  ### Recommendation
  - **Broadcast cadence:** `setPatchRate(TICK_MS=50)` (current value). At 30 Hz client send + 20 Hz server broadcast, every client snapshot reflects ≤ 50 ms-old data. Acceptable.
  - **No downsampling.** Concrete bandwidth budget: 50 CCU × 30 Hz × ~25 B msgpackr-encoded PlayerState diff per peer = ~37 kbps down per client; aggregate ~15 Mbps over WS. Fly.io shared-cpu-1x is rated 1000s of Mbps NIC throughput — not a constraint.
  - **Defer optimization** until a Phase 7 60 CCU+ stress test demands it. Premature.

  ## Recommended `position_update` Wire Schema

  ### TypeScript shape (zod, JSON over Colyseus channel)

  ```typescript
  // packages/protocol/src/intents.ts — ADD after cSetFacingSchema
  export const cPositionUpdateSchema = z.object({
    type: z.literal('position_update'),
    // Int16 quantized — D-07. Range [-32768, 32767] safely covers BNCentral
    // 8000×6400 px and any room ≤ ~30000 px. Snap-rounded by client (Math.round).
    x: z.number().int().min(-32768).max(32767),
    y: z.number().int().min(-32768).max(32767),
    // BNO instant-set/instant-stop model means vx/vy are typically -5..+5 px/tick.
    // Int16 range trivially holds this with headroom for any future faster items.
    vx: z.number().int().min(-32768).max(32767),
    vy: z.number().int().min(-32768).max(32767),
    // Facing — D-10. Same enum as cSetFacingSchema (legacy compat).
    facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    // anim_state — D-08. Packed 1-byte enum (see byte layout below).
    anim_state: z.number().int().min(0).max(255),
    // seq — keep per Pattern 4. Per-account, monotonic.
    seq: z.number().int().min(0),
    // monotonic_at_ms — 06.4 D-58c precedent; staleness detection without trusting wall-clock.
    monotonic_at_ms: z.number().nonnegative(),
  }).strict();
  ```

  ### Byte layout (binary packing — if Phase 7 demands further compression)

  | Field | Type | Size | Range | Notes |
  |-------|------|------|-------|-------|
  | `x` | int16 LE | 2 B | -32 768..32 767 | Pixel-quantized; client `Math.round()` before send |
  | `y` | int16 LE | 2 B | -32 768..32 767 | Same |
  | `vx` | int16 LE | 2 B | -32 768..32 767 | Typically -5..+5 (RUN_SPEED) |
  | `vy` | int16 LE | 2 B | -32 768..32 767 | Same |
  | `facing` | u8 | 1 B | 0..7 | Octant index (see enum below) |
  | `anim_state` | u8 | 1 B | 0..255 | Packed (see below) |
  | `seq` | u32 LE | 4 B | 0..4 294 967 295 | Per-account monotonic |
  | `monotonic_at_ms` | u32 LE | 4 B | 0..~50 days | Wraps roughly every 50 days — acceptable for a 06.4 D-58c precedent value |
  | **Total payload** | | **18 B** | | |

  **Wire-frame overhead:** Colyseus message-channel framing adds ~10-20 B (channel name "position_update" + msgpackr keyed-object header). End-to-end frame size ~30-40 B. At 30 Hz × 50 CCU upstream that's ~50-60 kbps per server NIC for c2s; trivial.

  ### facing enum (octant index)

  ```typescript
  export const FACING_OCTANT: Record<Direction, number> = {
    R:  0,  // 0°
    UR: 1,  // 45°
    U:  2,  // 90°
    UL: 3,  // 135°
    L:  4,  // 180°
    DL: 5,  // 225°
    D:  6,  // 270°  ← BNO spawn default
    DR: 7,  // 315°
  };
  ```

  ### anim_state byte layout (packed 1-byte enum)

  ```
  bit 7  6  5  4  3  2  1  0
         |reserved | |R| |octant|
                    │   └── 3 bits: facing octant 0..7 (mirrors `facing` field — see note)
                    └── bit 3: 1 = running, 0 = standing
         └── bits 4-7: RESERVED for future poses (jump=0x10, hit=0x20, etc.)
  ```

  Mapped values for the 16 base poses:

  | Pose | Byte | Description |
  |------|------|-------------|
  | `STAND_D`  | `0x06` | facing=D (6), running=0 |
  | `STAND_DR` | `0x07` | facing=DR (7), running=0 |
  | `STAND_R`  | `0x00` | facing=R (0), running=0 |
  | `STAND_UR` | `0x01` | facing=UR (1), running=0 |
  | `STAND_U`  | `0x02` | facing=U (2), running=0 |
  | `STAND_UL` | `0x03` | facing=UL (3), running=0 |
  | `STAND_L`  | `0x04` | facing=L (4), running=0 |
  | `STAND_DL` | `0x05` | facing=DL (5), running=0 |
  | `RUN_D`    | `0x0E` | facing=D (6), running=1 (0x08) |
  | `RUN_DR`   | `0x0F` | facing=DR (7), running=1 |
  | `RUN_R`    | `0x08` | facing=R (0), running=1 |
  | `RUN_UR`   | `0x09` | facing=UR (1), running=1 |
  | `RUN_U`    | `0x0A` | facing=U (2), running=1 |
  | `RUN_UL`   | `0x0B` | facing=UL (3), running=1 |
  | `RUN_L`    | `0x0C` | facing=L (4), running=1 |
  | `RUN_DL`   | `0x0D` | facing=DL (5), running=1 |

  **Note on the facing-vs-anim_state redundancy:** `facing` (top-level field) and `anim_state` bits 0-2 carry the same octant on the wire — this looks redundant. It is intentional per D-10: `facing` = "where the player is pointing" (intent / D-58c set_facing semantics); `anim_state` = "what pose to RENDER right now". The two diverge during HexportIn (`set_sprite_override`) where `facing` may stay at D but rendered pose is the override. Cost of the duplication: 1 byte. Keep both.

  **Reserved bits 4-7** future-proof for jump (`0x10`), hit/stun (`0x20`), watching (`0x30` = set via `set_sprite_override` but mirrored here for state-diff completeness). Up to 16 future poses with zero protocol change.

  ### `set_sprite_override` schema (rare event)

  ```typescript
  // packages/protocol/src/intents.ts — ADD after cPositionUpdateSchema
  export const cSetSpriteOverrideSchema = z.object({
    type: z.literal('set_sprite_override'),
    sprite_id: z.enum([
      'hexport_in', 'hexport_out',
      'tele_in', 'tele_out',
      'ncol_1', 'ncol_2', 'ncol_3', 'ncol_4',
      'jokershell',
      'watching',
      'none',  // clear override
    ]),
    // No coords/velocity — the override is layered atop the position_update stream.
  }).strict();
  ```

  Override semantics (server side): write `PlayerState.sprite_override: string` (new field — additive). Client renderer prefers `sprite_override` over `anim_state` when non-`'none'`. Override is cleared either by the client sending `set_sprite_override(none)` or — once 06.8 lands — by the fall-reset path.

  **This phase ships `set_sprite_override` as a stub** — handler + schema + state field, but no consumer wiring in the renderer (since the listed special poses are all post-MVP). The wiring is forward-compat: when PAR-* phases add Hexport / TeleIn animations, the protocol surface already exists.

  ## Treatment of `seq`, `last_input_seq`, server-side `step()`, broadcast cadence

  ### `seq` / `last_input_seq` — KEEP

  **Recommendation:** Keep both fields exactly as Pattern 4 describes. They are already in the protocol, already on `PlayerState`, and cost 4 B/frame on the wire — trivial for the staleness-detection and reconnect-coherency benefits.

  **Concrete server-side use:**
  ```typescript
  if (parsed.data.seq <= player.last_input_seq) {
    // Out-of-order position_update — discard. Rare under TCP but cheap to defend.
    return;
  }
  player.last_input_seq = parsed.data.seq;
  ```

  ### Server-side `step()` for self-player platform effects — DROP from self-player path

  **Recommendation:** Stop running `step()` for self-player position derivation entirely. Run `step()` ONLY for platforms (REQ-SRV-14) and any future world simulation.

  **Rationale:** Once the client is the authoritative source for `{x, y, vx, vy, facing, anim_state}`, the server has no business running collision/platform-carry logic against the position the client just declared. If the client is on a conveyor platform, the client's `predictor.ts` already runs `step()` locally with the platform state in its WorldState (it reads platforms from the broadcast `PlatformState`). The position the client sends to the server already includes the platform-carry displacement; the server applying it again would double-count.

  **The clean architecture:**
  - Client `predictor.ts` runs `step()` locally with full WorldState (platforms + self + collision grid).
  - Client sends `{x, y, vx, vy, ...}` reflecting the post-step values.
  - Server stores them. Done.
  - Server's own `step()` advances platforms (so the broadcast PlatformState stays fresh) but does NOT touch player position.

  This is the simplest, fewest-moving-parts design and matches Minecraft `[CITED: minecraft.wiki/w/Tick — server tick advances world state, client sends Set Player Position]`.

  **Forward-compat concern (06.8 Phase B):** when the fall trigger lands, the server WILL need a server-side walkable check — but that's a *read-only* check against the client-reported position, NOT a re-derivation. Adding it is orthogonal to running step() for the player.

  ### Broadcast cadence — FULL 30 Hz (= current setPatchRate)

  **Recommendation:** No change. `setPatchRate(TICK_MS=50)` stays — that's 20 Hz broadcast against a 30 Hz client-send. Server stores 30 Hz of updates but only fans out the latest at 20 Hz. Bandwidth-per-client at 50 CCU stays ~37 kbps. Defer downsampling until profiling shows fan-out is a bottleneck.

  Actually — re-examine: the current `setPatchRate(TICK_MS=50)` aligns broadcast with the server tick. With the new model the server tick still runs at 20 Hz (accumulator.ts) for platforms. Keeping `setPatchRate(TICK_MS=50)` is the right call; it aligns broadcast with the server's own simulation cadence and the existing extrapolator's 250 ms cap.

  **Why not match patch rate to the client send rate (30 Hz)?** Two reasons: (1) the server isn't computing player position anymore, so faster broadcast adds no per-tick fidelity; (2) state-diff batching at 50 ms naturally coalesces multiple 33 ms client sends into one network frame downstream, saving bandwidth. Win-win.

  ## Common Pitfalls

  ### Pitfall 1: Reconciler-snap regression

  **What goes wrong:** A future tweak to `reconciler.ts` accidentally re-enables the self-player write path (e.g., someone refactors and removes the early return).

  **Why it happens:** The current self-reconciler is the source of the diagonal-stop drift bug; the temptation to "just fix the threshold" rather than fully no-op is real.

  **How to avoid:** Add an explicit unit test asserting `ReconcileEngine.onServerSnapshot(snap, isSelfPlayer=true)` produces **zero** calls to `sprite.setPosition` and **zero** calls to `sprite.tweenTo`. D-04 calls this out as an acceptance criterion. Pair with a comment in `reconciler.ts` header explaining the invariant.

  **Warning signs:** Operator UAT reports "10 px diagonal shift", "rubber-banding on stop", or "visible jolt on connection blip". Diagnostic ring-buffer `globalThis.__rebno.lastReconcile*` (still useful for debugging remote-path reconciler if retained) will show `lastReconcileReason: 'divergence-threshold'` with self account_id — instant red flag.

  ### Pitfall 2: Stale local prediction after force-resume

  **What goes wrong:** Client reconnects after SRV-06's 10 s grace; server has the LAST-broadcast position (the one BEFORE the disconnect). Client's local predictor has been running the whole time and is now ahead. First position_update post-reconnect tells the server the new (correct) position.

  **Why it's actually fine:** This is the model working as designed. Client is the authoritative source; server adopts client's value on the next position_update. The only visible effect is a single frame where remote viewers see the player at the pre-disconnect position before the post-reconnect update lands — invisible at 30 Hz.

  **How to avoid worry:** Test it explicitly. Server `reconnect.integ.test.ts` should assert: disconnect → reconnect → first position_update overwrites server's stored x/y. (Likely a tiny addition to an existing test.)

  ### Pitfall 3: Position_update spam from a runaway client

  **What goes wrong:** Bug in client send-loop causes 1000+ position_updates per second; server token-bucket drops most but processing each parse still costs CPU.

  **Why it happens:** Client tick loops can degenerate; an infinite loop in render code could starve the tick scheduler and then catch up by firing many in a row.

  **How to avoid:** Reuse the existing token bucket. Add `'position_update'` as a new msg_type to `TokenBucket` per CONTEXT D-22 + Plan 04-08. Suggested budget: refill at 35 tokens/sec, burst capacity 60 (gives 30 Hz + 5/sec headroom for reconnect retransmits, mutes at 60). The cheap-path-before-zod pattern from `onMessageHandlers.ts:69-96` is the right design.

  **Warning signs:** `rate_limit_dropped` log entries spike for `msg_type: 'position_update'`. Operator dashboard should plot this.

  ### Pitfall 4: Forgetting to bump PROTOCOL_VERSION

  **What goes wrong:** Old v3 clients send axes-shape `c2s.input` payloads; new v4 server only handles `position_update`. Mixed-version traffic silently degrades (movement becomes a no-op for the v3 client because nothing writes its position anymore).

  **Why it happens:** The version-bump ritual is documented in `packages/protocol/src/version.ts` history block; easy to skip in the rush of refactoring.

  **How to avoid:** Bump `PROTOCOL_VERSION = 4` and update the history block. The existing `validateAuthFrame` in `intents.ts:285-299` will reject v3 clients with `PROTOCOL_VERSION_MISMATCH` at handshake — exactly the right behavior under D-12 hard-cut deploy. Phase-plan task should call this out explicitly with the deploy-ritual checklist.

  **Warning signs:** Mixed-version is impossible by construction once the server rejects v3; the only way this bug manifests is if the version bump is forgotten. Test: `apps/server/test/protocol-v2-handshake.integ.test.ts` precedent — add a `protocol-v4-handshake` variant.

  ### Pitfall 5: PlayerState schema extension breaks Colyseus delta-encoder

  **What goes wrong:** Adding `anim_state` and `sprite_override` fields to `PlayerState` in `packages/protocol/src/state.ts` requires Colyseus schema-add discipline. `[VERIFIED: state.ts:11-13 comment block]` explicitly warns: "Adding fields to a `@colyseus/schema` class is wire-incompatible with v1 clients per RESEARCH §A5 — the deploy ritual is non-negotiable."

  **Why it happens:** Easy to add a field, forget to bump version, mismatched-schema clients get garbled state.

  **How to avoid:** Same deploy ritual as the PROTOCOL_VERSION bump. The version-history comment in `version.ts` notes "ADR 0007 locks the contract" — read ADR 0007 and ensure entry 4 covers position_update schema. New state.ts fields:
  ```typescript
  @type('number') anim_state: number = 0x06;  // default = STAND_D
  @type('string') sprite_override: string = 'none';
  ```

  ### Pitfall 6: Phaser tween fighting predictor

  **What goes wrong:** Old reconciler called `sprite.tweenTo(x, y, 100)`. If the no-op branch isn't entered correctly, a running tween could still be in-flight at the moment predictor.predictTick writes new sprite.setPosition — visual fight, jerky movement.

  **Why it happens:** Phaser tweens hold a reference to the sprite and apply incrementally; setPosition during a tween creates a race.

  **How to avoid:** When removing the self-reconciler tween path, ALSO ensure GameScene cancels any in-flight tween targeted at the local sprite before the predictor writes. Pattern: `scene.tweens.killTweensOf(localSprite)` in the predictor-write code path. Test surface: `apps/client/src/__test__/game-scene.test.ts`.

  ### Pitfall 7: msgpackr Buffer vs Uint8Array normalization (carry-forward)

  **What goes wrong:** Server emits a position_update derived value via `encodeS2C` (Buffer instance on Node); client expects Uint8Array. The existing `decodeS2C` in `events.ts:80-94` has `normaliseBinary` for `room_layout` — if a new S2C event is added in 06.7 that carries binary, it needs the same normalization.

  **Why it's flagged:** Phase A doesn't add new S2C binary fields (the broadcast goes via Colyseus state-diff, not msgpackr) — but if planning adds an `s2c.position_broadcast` event (rejected option from "Alternatives Considered"), this Pitfall reactivates.

  **How to avoid:** Stick to Colyseus state-diff. If a parallel binary event channel becomes necessary in 06.8, mirror the `normaliseBinary` pattern.

  ### Pitfall 8: Colyseus 0.17 `players.onChange` doesn't fire per-field

  **What goes wrong:** `[VERIFIED: colyseus-client.ts:241-250 comment]` documents this: `players.onChange` only fires on Map key add/remove, NOT on per-field updates. Existing client uses `$(player).onChange(...)` inside `onAdd` to catch per-field deltas. New `anim_state` / `sprite_override` fields fall under the same per-instance handler — no new wiring needed, just verify the existing handler reads them.

  **Warning signs:** Remote avatar plays wrong animation pose despite server logs showing correct `PlayerState.anim_state`. Diagnostic: log `player.anim_state` inside `$(player).onChange` callback in dev builds.

  ## Code Examples

  ### Adding `cPositionUpdateSchema` to the discriminated-union

  ```typescript
  // packages/protocol/src/intents.ts — ADD to c2sSchema
  // Source: pattern from intents.ts:120-127 (existing c2sSchema definition)
  export const c2sSchema = z.discriminatedUnion('type', [
    cAuthSchema,
    cInputSchema,           // KEEP — chat-pause uses pauseMovement → sendInput(0,0); also test harness path
    cChatSendSchema,
    cRoomJoinSchema,
    cHeartbeatSchema,
    cSetFacingSchema,
    cPositionUpdateSchema,   // NEW (06.7)
    cSetSpriteOverrideSchema, // NEW (06.7)
  ]);
  ```

  **Decision call:** keep `cInputSchema`? Yes — for two reasons:
  1. `input-dispatcher.ts:298` `pauseMovement()` sends `{axes: {x:0, y:0}}` to clear server held-input state during chat-input focus. Easier to keep this contract than to remove it.
  2. Test harnesses poke `inputBuffer` directly via `cInputSchema` (per `onMessageHandlers.ts:21-23`). Removing breaks tests.

  **Server-side mechanics:** with the carve-out, the `c2s.input` handler in `onMessageHandlers.ts:104-136` still writes into `heldInputs`, but `tickLoop` no longer reads `heldInputs` for player position. Heldinputs becomes vestigial-but-harmless. Plan-task: leave it for 06.7; remove in a 06.9 cleanup once 06.8 ships.

  ### Position dispatcher (client, every-tick send)

  ```typescript
  // apps/client/src/prediction/position-dispatcher.ts  (NEW FILE)
  // [impl->REQ-CLI-04] [impl->REQ-CLI-08]
  // Source: pattern derived from input-dispatcher.ts:256-286 sendInput
  import type { Room } from '@colyseus/sdk';
  import type { PredictionEngine } from './predictor.js';
  import type { SpriteStateMachine } from '../render/SpriteStateMachine.js';
  import { packAnimState, FACING_OCTANT } from '@rebno/protocol';

  export class PositionDispatcher {
    private nextSeq = 0;
    constructor(
      private readonly room: Pick<Room, 'send'>,
      private readonly prediction: PredictionEngine,
      private readonly getFacing: () => Direction,
      private readonly getIsRunning: () => boolean,
    ) {}

    /** Called once per sim tick (30 Hz) by GameScene.update. */
    sendTick(monotonic_at_ms: number): void {
      const sim = this.prediction.getLocalState();
      const facing = this.getFacing();
      const isRunning = this.getIsRunning();
      this.room.send('position_update', {
        type: 'position_update',
        x: Math.round(sim.x),
        y: Math.round(sim.y),
        vx: Math.round(sim.vx),
        vy: Math.round(sim.vy),
        facing,
        anim_state: packAnimState(facing, isRunning),
        seq: ++this.nextSeq,
        monotonic_at_ms,
      });
    }
  }
  ```

  ### Server tickLoop after the change

  ```typescript
  // apps/server/src/RebnoRoom.ts — MODIFIED tickLoop (compare to existing :1274-1315)
  private tickLoop(realDt: number): void {
    const { newAcc, ticks } = advanceAccumulator(this.accumulator, realDt);
    this.accumulator = newAcc;
    if (realDt > 100 || ticks > 4) {
      log.warn({ event: 'tick_loop_slow', realDt, ticks, roomId: this.roomId, playerCount: this.state.players.size },
        'tick loop slow — event-loop stall suspected');
    }
    for (let i = 0; i < ticks; i++) {
      // Server step() ONLY for platforms (REQ-SRV-14).
      // Player position is no longer derived here — it's written by the
      // position_update handler. Pass an empty inputs map: with the player
      // pass-through change in step.ts (Pattern 2 above), the players map
      // round-trips unchanged.
      const next = step(this.toWorldState(), new Map(), TICK_MS);
      this.applyToColyseusState(next);
    }
  }
  ```

  ## State of the Art

  | Old Approach | Current Approach | When Changed | Impact |
  |--------------|------------------|--------------|--------|
  | Quake3 server-authoritative simulation + reconciler | Minecraft-style client-trusted position + corrective override (the "fall trigger" backstop in 06.8) | Modern small-scale multiplayer (post-~2015) | Predictable feel, simpler server, smaller anti-cheat surface for trusted player pools |
  | Event-driven axes input | Per-tick absolute position streaming | The phase | Single dropped packet recovers naturally; no held-state divergence |
  | Hybrid prediction + threshold-snap reconciler | Pure client-feel for self, server-broadcast for remote | The phase | Removes the diagonal-stop ~10 px drift root cause |

  **Deprecated/outdated (post-06.7):**
  - `apps/client/src/prediction/reconciler.ts` `globalThis.__rebno.lastReconcile*` writes for the self-player — remove cleanly. Operator diagnostic still useful if a remote-snap path is added in 06.8 (it currently isn't called for remotes either, but the symmetry is worth preserving until 06.8 confirms).
  - `DIVERGENCE_THRESHOLD_PX = 22` constant — stays defined but unused. Mark `@deprecated 06.7` in the comment.

  ## Assumptions Log

  | # | Claim | Section | Risk if Wrong |
  |---|-------|---------|---------------|
  | A1 | `[ASSUMED]` Bandwidth at 50 CCU × 30 Hz × 25 B is ~15 Mbps aggregate / ~37 kbps per client | "Standard Stack" + "Dropped-Packet Tolerance" | Low — back-of-envelope is conservative; even if 3× off, still well under Fly.io shared-cpu-1x NIC budget |
  | A2 | `[ASSUMED]` Colyseus state-diff batches multiple 33-ms client sends into one 50-ms broadcast frame downstream | "Broadcast cadence" recommendation | Low — Colyseus docs `[CITED: docs.colyseus.io/state — "only state mutations are broadcasted at every patch"]` confirm property-level batching; the temporal coalescing is implied but not explicitly tested in this session |
  | A3 | `[ASSUMED]` Client `predictor.ts` already incorporates platform-carry into its local step() | "Server-side step() retention" — DROP recommendation | Medium — if `predictor.ts` does NOT consume PlatformState in its local WorldState, then dropping server-side player-position step() leaves the client unable to compute platform-carry locally. **Mitigation:** verify before implementation. Predictor.ts:105-115 builds `prevState.platforms: new Map()` (empty) — so as of today the client does NOT have full platform-carry. This is acceptable for 06.7 (MVP slice has no platforms per CDOC/PAR-03 deferral) but the assumption needs explicit handoff to Phase 7 when platforms ship. Plan-phase should flag this. |
  | A4 | `[ASSUMED]` Token bucket budget of 35 tokens/sec, burst 60 is sufficient for position_update at 30 Hz | "Pitfall 3" | Low — rate-limit values are tunable post-staging; default starting point chosen with 5 Hz headroom |
  | A5 | `[ASSUMED]` PROTOCOL_VERSION bump from 3 → 4 is the right path (vs. additive optional fields) | "Pitfall 4" | Low — wire shape is genuinely changing (new `position_update` handler is the new authoritative source for player position); D-12 hard-cut + version-history convention `[VERIFIED: version.ts:14-22]` makes the bump the established practice |
  | A6 | `[ASSUMED]` Bits 4-7 of `anim_state` reserved for future poses (jump/hit/watching) covers needs through Phase 7 PAR-* | "anim_state byte layout" | Low — 4 reserved bits = 16 future poses; legacy BNO has ~6 special poses tracked in SEED |

  ## Open Questions (RESOLVED)

  1. **A3 — does client-side `predictor.ts` need to start consuming PlatformState?**
     - What we know: predictor.ts:105-115 builds a WorldState with `platforms: new Map()` (empty)
     - What's unclear: when Phase 7 adds platform-bearing rooms, will the position the client SENDS include platform-carry displacement, or will it omit it (causing visual sync to lag platforms)?
     - Recommendation: Plan-phase task should NOT add platform-carry to predictor.ts in 06.7 (MVP has no platforms). Add an explicit cross-reference comment in the predictor + flag this in PAR-03 planning.

  2. **Should `ReconcileEngine` be deleted entirely vs. neutered to a no-op?**
     - What we know: ReconcileEngine.onServerSnapshot is invoked from somewhere in GameScene (likely; need to confirm during planning). The current code already documents (06.4 round-3 comments) that remote players go through PlayerRenderer, not ReconcileEngine.
     - What's unclear: is there ANY caller of ReconcileEngine.onServerSnapshot in active client code post-06.7? If not, the cleanest action is full deletion of the file + test.
     - Recommendation: Planner should grep `apps/client/src` for `ReconcileEngine` and `onServerSnapshot` callers. If zero non-test callers, full deletion is safer than no-op-with-trapdoor. Document the diagnostic-ringbuffer-loss tradeoff.

  3. **Should `c2s.input` and `heldInputs` be removed in 06.7 or deferred to 06.9 cleanup?**
     - What we know: `cInputSchema` + heldInputs are vestigial once position_update lands (server.step() no longer reads heldInputs for player position). `pauseMovement()` chat-focus contract uses cInputSchema.
     - What's unclear: removing immediately requires re-routing the chat-focus contract (probably to a new `c2s.pause_movement` no-op or just dropping it since position_update streaming with vx=vy=0 IS the new "paused" signal).
     - Recommendation: KEEP in 06.7. Cleanup phase (call it 06.9 or fold into 06.8) removes after the new model is operator-validated.

  4. **Should the protocol track a server-broadcast `last_received_at_server_monotonic_ms` for round-trip diagnostics?**
     - What we know: client sends `monotonic_at_ms`; server doesn't echo a corresponding timestamp.
     - What's unclear: would operators benefit from a c2s/server-stamp round-trip diagnostic for staging UAT?
     - Recommendation: defer; no signal from operator that this is needed. Position itself self-validates.

  ## Environment Availability

  Skipped — phase is purely code refactor against existing stack. All dependencies already pinned in `apps/server/package.json` and `apps/client/package.json`; no new tooling required.

  ## Validation Architecture

  ### Test Framework
  | Property | Value |
  |----------|-------|
  | Framework | `vitest` 4.1.5 (server) / 3.2.4 (client) `[VERIFIED: package.json files]` |
  | Config file | Implicit `vitest` config in each app + workspace |
  | Quick run command (server) | `pnpm -C apps/server test` |
  | Quick run command (client) | `pnpm -C apps/client test` |
  | Full suite command | `pnpm -r test` (workspace-recursive) |
  | Integration tests (server) | `pnpm -C apps/server test:integration` |

  ### Phase Requirements → Test Map

  | Req ID | Behavior | Test Type | Automated Command | File Exists? |
  |--------|----------|-----------|-------------------|-------------|
  | REQ-SRV-03 | server `position_update` handler validates with cPositionUpdateSchema (zod strict) and writes to PlayerState | unit | `pnpm -C apps/server test position-update` | Wave 0: NEW `apps/server/test/position-update.test.ts` |
  | REQ-SRV-03 | server `position_update` handler is rate-limited and rejects stale seq | unit | `pnpm -C apps/server test position-update-rate-limit` | Wave 0: NEW (covered in same file) |
  | REQ-SRV-03 | server `set_sprite_override` handler accepts valid sprite_id, writes to PlayerState.sprite_override | unit | `pnpm -C apps/server test sprite-override` | Wave 0: NEW `apps/server/test/sprite-override.test.ts` |
  | REQ-SRV-03 | end-to-end: client sends position_update → server stores → broadcasts → second client sees update | integration | `pnpm -C apps/server test:integration position-update` | Wave 0: NEW `apps/server/test/position-update.integ.test.ts` |
  | REQ-SRV-03 | PROTOCOL_VERSION 3 client rejected at handshake (close 4400) | integration | `pnpm -C apps/server test:integration protocol-v4-handshake` | Wave 0: NEW `apps/server/test/protocol-v4-handshake.integ.test.ts` (mirrors `protocol-v2-handshake.integ.test.ts`) |
  | REQ-SRV-14 | `step()` still advances platform positions (regression — platforms not broken by carve-out) | unit | `pnpm -C packages/game-logic test step.platforms` | ✅ existing `packages/game-logic/test/step.test.ts` — add platform-advance assertion if not present |
  | REQ-CLI-04 | reconciler.onServerSnapshot is a no-op for self-player — sprite.setPosition + sprite.tweenTo never called (D-04 acceptance criterion) | unit | `pnpm -C apps/client test reconciler-self-noop` | Wave 0: NEW assertion added to `apps/client/src/__test__/reconciler.test.ts` |
  | REQ-CLI-04 | PositionDispatcher sends position_update at 30 Hz on tick, correctly populated from PredictionEngine + SpriteStateMachine output | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW `apps/client/src/__test__/position-dispatcher.test.ts` |
  | REQ-CLI-04 | predictor continues to drive local sprite position on every tick (regression — local feel preserved) | unit | `pnpm -C apps/client test prediction` | ✅ existing `apps/client/src/__test__/prediction.test.ts` |
  | REQ-CLI-04 | anim_state packing — 16 base poses round-trip pack→unpack | unit | `pnpm -C packages/protocol test anim-state` | Wave 0: NEW `packages/protocol/test/anim-state.test.ts` |
  | REQ-CLI-08 | two-player smoke: A moves, B sees A move smoothly (no rubber-band, no diagonal-stop drift, no hitching on simulated packet delay) | e2e | `pnpm -C apps/client test:e2e two-player-movement` | partial — covered by existing CLI-08 e2e; ADD assertion for diagonal-stop drift regression |
  | REQ-CLI-08 | manual operator UAT on staging — diagonal-stop drift bug closed; dropped-packet hitching bug closed | manual-only | (operator session — captures in `06.7-HUMAN-UAT.md`) | Wave 0: NEW operator UAT script |

  **Manual-only justification (REQ-CLI-08 row 2):** the original two motivating bugs (D-02) are subjective movement-feel issues caught by the operator's eye, not by automated thresholds. The automated CLI-08 e2e covers structural correctness (positions update, broadcast happens, two players see each other); operator UAT closes the feel question. Standard for 06.* operator-UAT-gated phases (see `04-HUMAN-UAT.md` precedent).

  ### Sampling Rate
  - **Per task commit:** `pnpm -C <affected-package> test` (server / client / protocol / game-logic — only the changed one)
  - **Per wave merge:** `pnpm -r test` (workspace-recursive) + `pnpm -r typecheck` + `pnpm trace:check`
  - **Phase gate:** Full suite green + `pnpm -C apps/server test:integration` green + `pnpm -C apps/client test:e2e` green + operator UAT signed off before `/gsd-verify-work`

  ### Wave 0 Gaps
  - [ ] `apps/server/test/position-update.test.ts` — unit tests for cPositionUpdateSchema.safeParse rejection paths + handler PlayerState writes — covers REQ-SRV-03
  - [ ] `apps/server/test/position-update.integ.test.ts` — two-client roundtrip integration test — covers REQ-SRV-03
  - [ ] `apps/server/test/sprite-override.test.ts` — handler + schema — covers REQ-SRV-03
  - [ ] `apps/server/test/protocol-v4-handshake.integ.test.ts` — version-bump rejection — covers REQ-SRV-03
  - [ ] `apps/client/src/__test__/position-dispatcher.test.ts` — dispatcher unit — covers REQ-CLI-04
  - [ ] `packages/protocol/test/anim-state.test.ts` — packAnimState/unpackAnimState round-trip — covers REQ-CLI-04
  - [ ] `apps/client/src/__test__/reconciler.test.ts` — ADD self-noop assertion (file exists; new test case) — covers REQ-CLI-04 D-04 acceptance
  - [ ] Update existing `apps/client/src/__test__/prediction.test.ts` — confirm predictor still drives sprite (regression guard) — covers REQ-CLI-04
  - [ ] `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — operator UAT script covering diagonal-stop drift + simulated-packet-delay hitching — covers REQ-CLI-08

  *Framework install: none — `vitest` already pinned and runs in every package.*

  ## Security Domain

  > Phase-relevant ASVS categories. REBNO's `security_enforcement` is implicit-on (no explicit `false` in config.json `workflow` block).

  ### Applicable ASVS Categories

  | ASVS Category | Applies | Standard Control |
  |---------------|---------|-----------------|
  | V2 Authentication | yes | Better-Auth + argon2id (unchanged — auth runs at room onAuth before any position_update can land) |
  | V3 Session Management | yes | Server-tagged identity from `client.auth.account_id`; wire-side identity REJECTED at zod parse (`.strict()`) — same pattern as existing `cInputSchema` |
  | V4 Access Control | yes | Server checks `auth` exists before accepting any position_update; rate-limit gate (token bucket) per (account_id, msg_type) |
  | V5 Input Validation | **yes — primary** | zod `cPositionUpdateSchema.strict()` validates every field, including int16 range on x/y/vx/vy, enum on facing, byte range on anim_state, monotonic on seq. Same pattern as existing schemas. |
  | V6 Cryptography | no | No new crypto in 06.7; manifest signatures for room layouts unchanged |

  ### Known Threat Patterns for {Colyseus 0.17 + Phaser 3.90 + zod stack}

  | Pattern | STRIDE | Standard Mitigation |
  |---------|--------|---------------------|
  | Forged sender identity on the wire (client claims to be another account) | Spoofing | `.strict()` on cPositionUpdateSchema rejects any extra fields including `account_id`/`pid`; server tags identity from `client.auth.account_id` (existing pattern, `onMessageHandlers.ts:107-110`) |
  | Malicious zod payload (huge / deeply-nested) | DoS | int16 bounds + 1-byte enum bounds + bounded string for facing enum + non-negative for seq cap memory cost per parse to bytes |
  | Position-update spam | DoS | Token bucket per (account_id, 'position_update') — same pattern as existing rate limits, suggested 35 tokens/sec / burst 60 |
  | Position spoofing for advantage (teleport) | Tampering | **Accepted in 06.7 per D-11** — fall-trigger reset is 06.8 mitigation; trusted operator pool in interim |
  | Stale / out-of-order updates from TCP reorder | Tampering (mild) | seq monotonic check: `if (parsed.data.seq <= player.last_input_seq) return;` |
  | `anim_state` corruption causing client renderer crash | Tampering | int range cap (0..255) + client-side enum-table lookup (already a defensive pattern in SpriteStateMachine: unknown frame keys would fall through to STAND_D) |

  **Threat acceptance note (D-11):** position spoofing is acknowledged as accepted risk. The threat-modeling row above is for documentation continuity; CLAUDE.md Hard Rule 1 carve-out per D-13 codifies the acceptance.

  ## Sources

  ### Primary (HIGH confidence)
  - **Codebase (this session):** apps/server/src/RebnoRoom.ts, apps/server/src/onMessageHandlers.ts, apps/client/src/prediction/reconciler.ts, apps/client/src/prediction/predictor.ts, apps/client/src/prediction/input-dispatcher.ts, apps/client/src/net/colyseus-client.ts, packages/protocol/src/{intents,state,events,version}.ts, packages/game-logic/src/{step,sprite-state-machine,constants,accumulator}.ts, apps/client/src/render/SpriteStateMachine.ts, package.json files
  - **CONTEXT.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md` — all D-01..D-16 decisions
  - **SEED.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-SEED.md` — original problem statement

  ### Secondary (MEDIUM confidence)
  - [Minecraft Wiki: Java Edition protocol/Packets](https://minecraft.wiki/w/Java_Edition_protocol/Packets) — Set Player Position, Set Player Position and Rotation, Synchronize Player Position packets (verified field layouts; byte estimates; clientbound vs serverbound)
  - [Minecraft Wiki: Java Edition protocol/FAQ](https://minecraft.wiki/w/Java_Edition_protocol/FAQ) — protocol overview
  - [Minecraft Wiki: Tick](https://minecraft.wiki/w/Tick) — "20 ticks per second" canonical rate; client position updates "on each tick"
  - [Quake 3 Source Code Review: Network Model (Fabien Sanglard)](https://fabiensanglard.net/quake3/network.php) — client-prediction + server-reconcile + delta-compression mechanics (Quake lineage comparator)
  - [Quake 3 Networking Primer (ra.is/unlagged)](https://www.ra.is/unlagged/network.html) — delta-compression and packet-loss recovery details
  - [Wikipedia: Client-side prediction](https://en.wikipedia.org/wiki/Client-side_prediction) — sequence-number reconciliation pattern (Pattern 4)
  - [Colyseus docs: State Synchronization](https://docs.colyseus.io/state) — property-level delta encoding; `setPatchRate` default 50ms
  - [Colyseus docs: WebSocket (Default) transport](https://docs.colyseus.io/server/transport/ws) — TCP-based reliable + ordered delivery confirmation
  - [Colyseus docs: Best Practices](https://docs.colyseus.io/state/best-practices) — delta-compression and binary-encoding overview

  ### Tertiary (LOW confidence — flagged for validation)
  - No tertiary-only sources used. Every claim is supported either by codebase grep (HIGH) or by at least one MEDIUM source corroborated against another.

  ## Metadata

  **Confidence breakdown:**
  - **Standard stack:** HIGH — every version pinned in this session against `package.json` files; no new dependencies
  - **Architecture:** HIGH — patterns derived from existing 06.4 D-58c set_facing precedent and verified Minecraft analog
  - **Wire schema:** HIGH — int16 bounds + zod patterns mirror existing `cInputSchema` and `cSetFacingSchema` exactly
  - **Pitfalls:** HIGH — direct from operator UAT logs (SEED), version-bump history (`version.ts` comments), Phaser tween idioms (in-file comments)
  - **Broadcast-cadence recommendation:** MEDIUM — bandwidth math is back-of-envelope; will validate against Fly.io staging telemetry post-deploy
  - **Server-side `step()` retention:** MEDIUM — recommendation is sound but A3 (platform-carry assumption) needs explicit handoff to Phase 7 planner

  **Research date:** 2026-05-17
  **Valid until:** 2026-06-17 (30 days — stable stack, no breaking version pins to track)

  ### Validation Strategy (VALIDATION.md)
  ---
  phase: 06.7
  slug: network-model-client-trust-fall-trigger
  status: draft
  nyquist_compliant: true
  wave_0_complete: false
  created: 2026-05-17
  ---

  # Phase 06.7 — Validation Strategy

  > Per-phase validation contract for feedback sampling during execution.
  > Substance extracted from `06.7-RESEARCH.md` §Validation Architecture (line 747) per
  > plan-checker iteration 1 blocker fix.

  ---

  ## Test Infrastructure

  | Property | Value |
  |----------|-------|
  | **Framework** | `vitest` 4.1.5 (server) / 3.2.4 (client) `[VERIFIED: package.json files]` |
  | **Config file** | Implicit `vitest` config in each app + workspace |
  | **Quick run command (server)** | `pnpm -C apps/server test` |
  | **Quick run command (client)** | `pnpm -C apps/client test` |
  | **Full suite command** | `pnpm -r test` (workspace-recursive) |
  | **Integration tests (server)** | `pnpm -C apps/server test:integration` |
  | **Estimated runtime** | ~45s per-package quick run; ~3min workspace-recursive |

  ---

  ## Sampling Rate

  - **After every task commit:** `pnpm -C <affected-package> test` (server / client / protocol / game-logic — only the changed one)
  - **After every plan wave:** `pnpm -r test` + `pnpm -r typecheck` + `pnpm trace:check`
  - **Before `/gsd-verify-work`:** Full suite green + `pnpm -C apps/server test:integration` green + `pnpm -C apps/client test:e2e` green + operator UAT signed off
  - **Max feedback latency:** < 60s for quick run; < 5min for wave gate

  ---

  ## Per-Task Verification Map (Phase Requirements → Test Map)

  | Req ID | Behavior | Test Type | Automated Command | File Exists? |
  |--------|----------|-----------|-------------------|-------------|
  | REQ-SRV-03 | server `position_update` handler validates with cPositionUpdateSchema (zod strict) and writes to PlayerState | unit | `pnpm -C apps/server test position-update` | Wave 0: NEW `apps/server/test/position-update.test.ts` |
  | REQ-SRV-03 | server `position_update` handler is rate-limited and rejects stale seq | unit | `pnpm -C apps/server test position-update-rate-limit` | Wave 0: NEW (covered in same file) |
  | REQ-SRV-03 | server `set_sprite_override` handler accepts valid sprite_id, writes to PlayerState.sprite_override | unit | `pnpm -C apps/server test sprite-override` | Wave 0: NEW `apps/server/test/sprite-override.test.ts` |
  | REQ-SRV-03 | end-to-end: client sends position_update → server stores → broadcasts → second client sees update | integration | `pnpm -C apps/server test:integration position-update` | Wave 0: NEW `apps/server/test/position-update.integ.test.ts` |
  | REQ-SRV-03 | PROTOCOL_VERSION 3 client rejected at handshake (close 4400) | integration | `pnpm -C apps/server test:integration protocol-v4-handshake` | Wave 0: NEW `apps/server/test/protocol-v4-handshake.integ.test.ts` (mirrors `protocol-v2-handshake.integ.test.ts`) |
  | REQ-SRV-14 | `step()` still advances platform positions (regression — platforms not broken by carve-out) | unit | `pnpm -C packages/game-logic test step.platforms` | ✅ existing `packages/game-logic/test/step.test.ts` — add platform-advance assertion if not present |
  | REQ-CLI-04 | reconciler.onServerSnapshot is a no-op for self-player — sprite.setPosition + sprite.tweenTo never called (D-04 acceptance criterion) | unit | `pnpm -C apps/client test reconciler-self-noop` | Wave 0: NEW assertion added to `apps/client/src/__test__/reconciler.test.ts` |
  | REQ-CLI-04 | PositionDispatcher sends position_update at 30 Hz on tick, correctly populated from PredictionEngine + SpriteStateMachine output | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW `apps/client/src/__test__/position-dispatcher.test.ts` |
  | REQ-CLI-04 | predictor continues to drive local sprite position on every tick (regression — local feel preserved) | unit | `pnpm -C apps/client test prediction` | ✅ existing `apps/client/src/__test__/prediction.test.ts` |
  | REQ-CLI-04 | anim_state packing — 16 base poses round-trip pack→unpack | unit | `pnpm -C packages/protocol test anim-state` | Wave 0: NEW `packages/protocol/test/anim-state.test.ts` |
  | REQ-CLI-04 | **D-06 idle cadence guard** — PositionDispatcher.sendTick emits valid payload when `getLocalState()` returns `{vx:0, vy:0}` (idle player still sends 30 Hz) | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW assertion in `apps/client/src/__test__/position-dispatcher.test.ts` (added per plan-checker iter 1 warning #3) |
  | REQ-CLI-08 | two-player smoke: A moves, B sees A move smoothly (no rubber-band, no diagonal-stop drift, no hitching on simulated packet delay) | e2e | `pnpm -C apps/client test:e2e two-player-movement` | partial — covered by existing CLI-08 e2e; ADD assertion for diagonal-stop drift regression |
  | REQ-CLI-08 | manual operator UAT on staging — diagonal-stop drift bug closed; dropped-packet hitching bug closed; idle-cadence ~30 Hz visible in DevTools Network | manual-only | (operator session — captures in `06.7-HUMAN-UAT.md`) | Wave 0: NEW operator UAT script |

  **Manual-only justification (REQ-CLI-08 row 2):** the two original motivating bugs (D-02) are subjective movement-feel issues caught by the operator's eye, not by automated thresholds. The automated CLI-08 e2e covers structural correctness (positions update, broadcast happens, two players see each other); operator UAT closes the feel question. Standard for 06.* operator-UAT-gated phases (see `04-HUMAN-UAT.md` precedent).

  ---

  ## Wave 0 Requirements

  - [ ] `apps/server/test/position-update.test.ts` — unit tests for `cPositionUpdateSchema.safeParse` rejection paths + handler `PlayerState` writes → REQ-SRV-03
  - [ ] `apps/server/test/position-update.integ.test.ts` — two-client roundtrip integration test → REQ-SRV-03
  - [ ] `apps/server/test/sprite-override.test.ts` — handler + schema → REQ-SRV-03
  - [ ] `apps/server/test/protocol-v4-handshake.integ.test.ts` — version-bump rejection → REQ-SRV-03
  - [ ] `apps/client/src/__test__/position-dispatcher.test.ts` — dispatcher unit (incl. D-06 idle-cadence assertion) → REQ-CLI-04
  - [ ] `packages/protocol/test/anim-state.test.ts` — `packAnimState`/`unpackAnimState` round-trip → REQ-CLI-04
  - [ ] `apps/client/src/__test__/reconciler.test.ts` — ADD self-noop assertion (file exists; new test case) → REQ-CLI-04 D-04 acceptance
  - [ ] Update existing `apps/client/src/__test__/prediction.test.ts` — confirm predictor still drives sprite (regression guard) → REQ-CLI-04
  - [ ] `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — operator UAT script covering diagonal-stop drift + simulated-packet-delay hitching + idle-cadence visibility → REQ-CLI-08

  *Framework install: none — `vitest` already pinned and runs in every package.*

  ---

  ## Manual-Only Verifications

  | Behavior | Requirement | Why Manual | Test Instructions |
  |----------|-------------|------------|-------------------|
  | Diagonal-stop drift closed (~10 px horizontal shift on stop no longer observed) | REQ-CLI-08 (D-02) | Movement-feel; needs human eye | Operator UAT Test 1 — walk diagonally on staging, release keys, observe no shift |
  | Dropped-packet hitching closed | REQ-CLI-08 (D-02) | Network-jitter perception | Operator UAT Test 2 — simulated packet delay via DevTools throttling; observe smooth interpolation |
  | Idle cadence visible at ~30 Hz | REQ-CLI-04 (D-06) | Wire-cadence visual confirmation | Operator UAT Test 6 (added per plan-checker iter 1 warning #3) — idle player; open DevTools Network panel; observe ~30 `position_update` frames/sec |
  | Rollback drill via `/gsd-undo` | REQ-CLI-08 (D-12, D-14) | Disaster-recovery rehearsal | Operator UAT Test 5 — run `/gsd-undo` on the 06.7 phase commits, verify staging reverts to 06.6 behavior |

  ---

  ## Validation Sign-Off

  - [x] All tasks have `<automated>` verify or Wave 0 dependencies
  - [x] Sampling continuity: no 3 consecutive tasks without automated verify
  - [x] Wave 0 covers all MISSING references
  - [x] No watch-mode flags
  - [x] Feedback latency < 60s for quick run
  - [x] `nyquist_compliant: true` set in frontmatter

  **Approval:** pending (plan-checker re-verification after iter 1 revision)

  ### Pattern Map (PATTERNS.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger — Pattern Map

  **Mapped:** 2026-05-17
  **Files analyzed:** 13 (5 modified + 6 new + 2 doctrine)
  **Analogs found:** 13 / 13

  ## File Classification

  | New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
  |-------------------|------|-----------|----------------|---------------|
  | `packages/protocol/src/intents.ts` | wire schema (MODIFY: add `cPositionUpdateSchema`, `cSetSpriteOverrideSchema`, `FACING_OCTANT`, `packAnimState`/`unpackAnimState`) | request-response (c2s) | `cSetFacingSchema` @ same file lines 93-98 + `cInputSchema` @ lines 45-59 | exact |
  | `packages/protocol/src/state.ts` | schema (MODIFY: add `anim_state` + `sprite_override` PlayerState fields) | event-driven (server→client state-diff) | `facing` field @ same file line 52 (06.4 D-58c precedent) | exact |
  | `packages/protocol/src/version.ts` | config (MODIFY: bump 3 → 4) | none | History block @ same file lines 14-22 | exact |
  | `apps/server/src/onMessageHandlers.ts` | server handler (MODIFY: add `position_update` + `set_sprite_override` handlers; extend rate-limit msg_type union) | request-response | `set_facing` handler @ same file lines 264-301 | exact |
  | `apps/server/src/RebnoRoom.ts` | room tick (MODIFY: tickLoop skips player input application; step() runs platforms only) | event-driven (per-tick) | Current tickLoop @ same file lines 1274-1315 | exact (in-place edit) |
  | `apps/client/src/prediction/position-dispatcher.ts` | client dispatcher (NEW) | request-response (per-tick send) | `InputDispatcher.sendInput` @ `apps/client/src/prediction/input-dispatcher.ts` lines 256-286 | exact |
  | `apps/client/src/prediction/reconciler.ts` | reconciler (MODIFY: self-player no-op via `isSelfPlayer` flag; remove globalThis ring-buffer for self) | event-driven (server-snapshot) | Current `ReconcileEngine.onServerSnapshot` @ same file lines 52-122 | exact (in-place edit) |
  | `apps/client/src/net/colyseus-client.ts` | client dispatcher wiring (MODIFY: instantiate PositionDispatcher; remove ReconcileEngine self-snapshot wiring) | event-driven | `bindHandlers` @ same file lines 237-258 | exact (in-place edit) |
  | `apps/server/test/position-update.test.ts` | test (NEW: zod + handler unit) | request-response | `apps/server/test/d58c-set-facing.integ.test.ts` + `packages/protocol/test/intents.test.ts` | exact |
  | `apps/server/test/position-update.integ.test.ts` | test (NEW: two-client integ) | request-response | `apps/server/test/event-driven-input.integ.test.ts` | exact |
  | `apps/server/test/sprite-override.test.ts` | test (NEW: handler + schema) | request-response | `apps/server/test/d58c-set-facing.integ.test.ts` | role-match |
  | `apps/server/test/protocol-v4-handshake.integ.test.ts` | test (NEW: version-bump rejection) | request-response | `apps/server/test/protocol-v2-handshake.integ.test.ts` | exact |
  | `apps/client/src/__test__/position-dispatcher.test.ts` | test (NEW: dispatcher unit) | request-response | `apps/client/src/__test__/input-dispatcher.test.ts` | exact |
  | `apps/client/src/__test__/reconciler.test.ts` | test (ADD case: D-04 self no-op assertion) | event-driven | Existing same file lines 52-93 | exact (additive case) |
  | `packages/protocol/test/anim-state.test.ts` | test (NEW: pack/unpack round-trip) | none | `packages/protocol/test/intents.test.ts` | role-match |
  | `packages/protocol/test/state.test.ts` | test (ADD case: anim_state + sprite_override defaults; bump PROTOCOL_VERSION === 4) | none | Existing same file lines 10-30 | exact (in-place edit) |
  | `CLAUDE.md` | doctrine (MODIFY: Hard Rule 1 movement carve-out per D-13) | none | Hard Rule 1 prose @ same file | exact |
  | `.planning/phases/06.7-.../06.7-HUMAN-UAT.md` | operator UAT script (NEW) | none | `.planning/phases/06.1-.../06.1-HUMAN-UAT.md` | role-match |

  ## Pattern Assignments

  ### `packages/protocol/src/intents.ts` (wire schema, c2s)

  **Analog:** `packages/protocol/src/intents.ts` itself — copy `cSetFacingSchema` and `cInputSchema` shape

  **Schema definition pattern** (lines 93-98, `cSetFacingSchema`):
  ```typescript
  export const cSetFacingSchema = z
    .object({
      type: z.literal('set_facing'),
      facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    })
    .strict();
  ```

  **Required-fields pattern** (lines 45-59, `cInputSchema` — keep `seq` + `monotonic_at_ms` precedent):
  ```typescript
  export const cInputSchema = z
    .object({
      type: z.literal('input'),
      seq: z.number().int().min(0),
      axes: z.object({ /* ... */ }).strict(),
      buttons_down: z.number().int().min(0).max(0xffff),
      buttons_up: z.number().int().min(0).max(0xffff),
      monotonic_at_ms: z.number().nonnegative(),
    })
    .strict();
  ```

  **Discriminated-union registration** (lines 120-127):
  ```typescript
  export const c2sSchema = z.discriminatedUnion('type', [
    cAuthSchema,
    cInputSchema,
    cChatSendSchema,
    cRoomJoinSchema,
    cHeartbeatSchema,
    cSetFacingSchema,
    // ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema
  ]);
  ```

  **Type-export pattern** (lines 129-135):
  ```typescript
  export type CSetFacing = z.infer<typeof cSetFacingSchema>;
  // ADD: CPositionUpdate, CSetSpriteOverride
  ```

  **Tag every export** with `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` per the precedent at line 83.

  ---

  ### `packages/protocol/src/state.ts` (schema, server→client)

  **Analog:** same file, `facing` field @ line 52 (06.4 D-58c additive precedent)

  **Additive field pattern** (line 52):
  ```typescript
  @type('string') facing: string = 'D';
  ```

  **Wire-version contract note** (lines 33-51 comment — copy this exact reasoning style for the new fields):
  > ADDITIVE FIELD — wire-compatible with Colyseus 0.17 schema-add (RESEARCH §Pitfall 2 + Assumption A2). Does NOT bump PROTOCOL_VERSION...

  For 06.7 the NEW fields DO trigger a PROTOCOL_VERSION bump (3 → 4) because the wire SHAPE (c2s.position_update) is genuinely new, not just an additive optional. The state.ts additions still follow the @colyseus/schema pattern:

  ```typescript
  // 06.7 NEW — add after `facing`:
  @type('number') anim_state: number = 0x06;  // default STAND_D (octant=6, running=0)
  @type('string') sprite_override: string = 'none';
  ```

  ---

  ### `packages/protocol/src/version.ts` (config)

  **Analog:** same file history block (lines 14-22)

  **Bump pattern** (line 24):
  ```typescript
  export const PROTOCOL_VERSION = 3 as const;
  // CHANGE to:
  export const PROTOCOL_VERSION = 4 as const;
  ```

  **History-block append pattern** (lines 14-22 — extend with v4 entry):
  ```typescript
  // History
  //  - 1: Phase 4 baseline (per-tick c2s.input shape + flat PlayerState)
  //  - 2: Phase 6 D-08/D-09 — adds PlayerState.axis_x_held/axis_y_held...
  //  - 3: Phase 6.2 D-51 — adds S2C force_reset variant...
  //  - 4: Phase 06.7 D-05/D-08/D-13 — flips player movement to CLIENT-AUTHORITATIVE
  //       absolute-position streaming. NEW c2s.position_update intent (30 Hz, int16
  //       quantized x/y/vx/vy + facing + anim_state + seq + monotonic_at_ms). NEW
  //       c2s.set_sprite_override event-only intent. PlayerState gains anim_state
  //       (u8 packed) + sprite_override (string). v3 clients close 4400 / MISMATCH.
  ```

  ---

  ### `apps/server/src/onMessageHandlers.ts` (server handler, request-response)

  **Analog:** same file, `set_facing` handler lines 264-301

  **Handler pattern with auth + rate-limit + zod + state-write** (lines 264-301):
  ```typescript
  ctx.room.onMessage('set_facing', (client: Client, raw: unknown) => {
    const auth = ctx.getAuth(client.sessionId);
    if (!auth) {
      log.warn({ sessionId: client.sessionId }, 'no_auth_for_session');
      return;
    }
    if (!rateLimitOrDrop(ctx, client, 'set_facing', auth.account_id)) return;
    const parsed = cSetFacingSchema.safeParse(raw);
    if (!parsed.success) {
      log.warn(
        { msg_type: 'set_facing', sessionId: client.sessionId, issue: parsed.error.message },
        'invalid_intent',
      );
      return;
    }
    const room = ctx.room as unknown as {
      state?: { players?: { get?: (sid: string) => { facing?: string } | undefined } };
    };
    const player = room.state?.players?.get?.(client.sessionId);
    if (!player) {
      log.warn(
        { sessionId: client.sessionId, account_id: auth.account_id },
        'set_facing_no_player_state',
      );
      return;
    }
    player.facing = parsed.data.facing;
  });
  ```

  **Rate-limit msg_type union extension** (line 72):
  ```typescript
  msg_type: 'input' | 'chat_send' | 'heartbeat' | 'room_join' | 'set_facing',
  // EXTEND TO:
  msg_type: 'input' | 'chat_send' | 'heartbeat' | 'room_join' | 'set_facing' | 'position_update' | 'set_sprite_override',
  ```

  **Stale-seq guard** (NEW for position_update only — derived from RESEARCH §Pattern 4):
  ```typescript
  // AFTER zod parse, BEFORE writing PlayerState:
  if (parsed.data.seq <= player.last_input_seq) return;
  player.last_input_seq = parsed.data.seq;
  player.x = parsed.data.x;
  player.y = parsed.data.y;
  // ... etc
  ```

  **Imports update** (lines 25-33):
  ```typescript
  import {
    cChatSendSchema,
    cInputSchema,
    cHeartbeatSchema,
    cRoomJoinSchema,
    cSetFacingSchema,
    encodeS2C,
    type S2C,
  } from '@rebno/protocol';
  // ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema
  ```

  ---

  ### `apps/server/src/RebnoRoom.ts` (room tick, event-driven)

  **Analog:** `tickLoop` in same file lines 1274-1315

  **Current player-input application pattern (TO REMOVE for self-player)** (lines 1289-1313):
  ```typescript
  for (let i = 0; i < ticks; i++) {
    const inputs = new Map<string, InputFrame>();
    for (const [account_id, frame] of this.inputBuffer) {
      inputs.set(account_id, frame);
    }
    this.inputBuffer.clear();
    for (const [account_id, held] of this.heldInputs) {
      inputs.set(account_id, { /* axis_x, axis_y, ... */ });
    }
    const next = step(this.toWorldState(), inputs, TICK_MS);
    this.applyToColyseusState(next);
  }
  ```

  **Target pattern after 06.7** (RESEARCH §Code Examples line 678-696):
  ```typescript
  for (let i = 0; i < ticks; i++) {
    // Server step() ONLY for platforms (REQ-SRV-14). Player position is
    // written by the position_update handler — pass empty inputs map.
    const next = step(this.toWorldState(), new Map(), TICK_MS);
    this.applyToColyseusState(next);
  }
  ```

  **Cleanup note:** the `heldInputs` map + `inputBuffer` writes are vestigial in 06.7 but KEPT per RESEARCH §Open Questions item 3 (06.9 cleanup deferred). The `c2s.input` handler still writes them; the tick loop just stops reading them for player position.

  ---

  ### `apps/client/src/prediction/position-dispatcher.ts` (client dispatcher, NEW)

  **Analog:** `InputDispatcher.sendInput` @ `apps/client/src/prediction/input-dispatcher.ts` lines 256-286

  **Class shape pattern** (lines 76-118 of input-dispatcher.ts — mutable `_room` + setter for reconnect):
  ```typescript
  export class InputDispatcher {
    private _room: Pick<Room, 'send'>;
    get room(): Pick<Room, 'send'> { return this._room; }
    setRoom(room: Pick<Room, 'send'>): void { this._room = room; }
    // ...
  }
  ```

  **Send-payload pattern** (lines 256-265):
  ```typescript
  private sendInput(axes: { x: Axis; y: Axis }): void {
    const intent = this.prediction.enqueueInput(axes);
    this._room.send('input', {
      type: 'input',
      seq: intent.seq,
      axes,
      buttons_down: 0,
      buttons_up: 0,
      monotonic_at_ms: intent.monotonic_at_ms,
    });
  }
  ```

  **Target shape (NEW) — RESEARCH §Code Examples 638-672:**
  ```typescript
  import type { Room } from '@colyseus/sdk';
  import type { PredictionEngine } from './predictor.js';
  import { packAnimState } from '@rebno/protocol';

  export class PositionDispatcher {
    private nextSeq = 0;
    constructor(
      private readonly room: Pick<Room, 'send'>,
      private readonly prediction: PredictionEngine,
      private readonly getFacing: () => Direction,
      private readonly getIsRunning: () => boolean,
    ) {}

    sendTick(monotonic_at_ms: number): void {
      const sim = this.prediction.getLocalState();
      const facing = this.getFacing();
      const isRunning = this.getIsRunning();
      this.room.send('position_update', {
        type: 'position_update',
        x: Math.round(sim.x),
        y: Math.round(sim.y),
        vx: Math.round(sim.vx),
        vy: Math.round(sim.vy),
        facing,
        anim_state: packAnimState(facing, isRunning),
        seq: ++this.nextSeq,
        monotonic_at_ms,
      });
    }
  }
  ```

  **Tag** with `// [impl->REQ-CLI-04] [impl->REQ-CLI-08]` per precedent at input-dispatcher.ts:1.

  ---

  ### `apps/client/src/prediction/reconciler.ts` (reconciler, MODIFY)

  **Analog:** same file `onServerSnapshot` lines 52-122

  **Self-player no-op pattern** (RESEARCH §Pattern 3 — replace the body with an early return when the snapshot is for self):
  ```typescript
  // Current signature: onServerSnapshot(snap: ServerSnapshot): void
  // CHANGE TO accept an isSelfPlayer flag (or detect via account_id comparison):
  onServerSnapshot(snap: ServerSnapshot, isSelfPlayer: boolean): void {
    if (isSelfPlayer) {
      // [impl->REQ-CLI-04] D-04 acceptance criterion: self-player sprite is
      // NEVER written by the snapshot path. Self-player position comes from
      // PredictionEngine.predictTick + InputDispatcher only.
      return;
    }
    // Remote-player path: currently unreachable from production callers
    // (PlayerRenderer.setRemotePosition handles remotes). Kept for symmetry.
  }
  ```

  **Drop the globalThis ring-buffer write** for the self-player branch (lines 73-92 + 100-119) — diagnostic surface is unused once the self-snapshot path is no-op. Mark `DIVERGENCE_THRESHOLD_PX = 22` with `@deprecated 06.7` per RESEARCH §State of the Art.

  **Header-comment update pattern** (lines 1-16):
  ```typescript
  // [impl->REQ-CLI-04]
  // 06.7 D-04 — ReconcileEngine.onServerSnapshot is a NO-OP for the self-player.
  // Self-player position is owned by the client (predictor.ts + PositionDispatcher);
  // snapshot-driven snap/lerp would re-introduce the diagonal-stop ~10 px drift
  // (operator UAT 2026-05-16). The remote-player branch is preserved for symmetry
  // but is not currently called — remote rendering goes through PlayerRenderer.
  ```

  ---

  ### `apps/client/src/net/colyseus-client.ts` (client dispatcher wiring, MODIFY)

  **Analog:** `bindHandlers` @ same file lines 237-258

  **Per-player onChange wiring** (lines 251-258):
  ```typescript
  players.onAdd((player: PlayerState, sessionId: string) => {
    if (sessionId === room.sessionId) cb.onLocalJoin?.(player, sessionId);
    else cb.onRemoteAdd?.(player, sessionId);
    $(player).onChange(() => {
      if (sessionId === room.sessionId) cb.onLocalSnapshot?.(player);
      else cb.onRemoteSnapshot?.(player, sessionId);
    });
  });
  ```

  **Modification:** Wire a `cb.onLocalSnapshot` callback that NO-OPS for self-player (or remove the callback entirely), and ensure `PositionDispatcher.sendTick(monotonic_at_ms)` is invoked from `GameScene.update` on the local sim-tick boundary. The dispatcher does NOT live in `bindHandlers` itself — instantiated where `InputDispatcher` is today and wired with the same `setRoom(room)` lifecycle for reconnect.

  **Pitfall 8 (RESEARCH §Pitfall 8):** the new `anim_state` / `sprite_override` PlayerState fields will fire `$(player).onChange` per-snapshot and the existing callback already reads "every field" — no new wiring needed beyond reading those values from `player.anim_state` / `player.sprite_override` in the renderer callbacks.

  ---

  ## Shared Patterns

  ### Authentication + identity (server-side)
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 104-110 (resolve auth FIRST before rate-limit / parse)
  **Apply to:** every new handler (`position_update`, `set_sprite_override`)
  ```typescript
  const auth = ctx.getAuth(client.sessionId);
  if (!auth) {
    log.warn({ sessionId: client.sessionId }, 'no_auth_for_session');
    return;
  }
  ```

  ### Rate-limit gate BEFORE zod parse
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 69-96 (`rateLimitOrDrop`) + line 112 usage
  **Apply to:** every new handler. RESEARCH §Pitfall 3 budgets: `position_update` → 35 tokens/sec / burst 60; `set_sprite_override` → existing default (rare event).
  ```typescript
  if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;
  ```

  ### `.strict()` zod validation + safeParse + warn
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 113-124
  **Apply to:** every new handler
  ```typescript
  const parsed = cPositionUpdateSchema.safeParse(raw);
  if (!parsed.success) {
    log.warn(
      { msg_type: 'position_update', sessionId: client.sessionId, issue: parsed.error.message },
      'invalid_intent',
    );
    return;
  }
  ```

  ### Server-tagged identity (never trust wire account_id)
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 183-188 (`auth.account_id`, NOT raw)
  **Apply to:** every new handler that writes to PlayerState. PlayerState key is `client.sessionId` (line 284), not a wire-supplied id.

  ### Tag-comment-then-code REQ traceability
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 246-247
  **Apply to:** every new handler/file
  ```typescript
  // [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]
  ctx.room.onMessage('position_update', (client: Client, raw: unknown) => { /* ... */ });
  ```

  ### Test file naming + tag convention
  **Source:** all test files under `apps/server/test/*.test.ts` (unit) + `*.integ.test.ts` (integration)
  **Apply to:** every new test
  - Header comment with `[unit->REQ-*]` or `[int->REQ-*]` tags (e.g., `apps/server/test/d58c-set-facing.integ.test.ts:1-3`)
  - `import { describe, it, expect, afterEach } from 'vitest';` (line 18 of d58c-set-facing.integ.test.ts)
  - `spawnServer()` helper from `./test-utils.js` for integ
  - `joinAs()` helper pattern (d58c-set-facing.integ.test.ts:38-59)
  - `mockAdapter()` / `makeMockRoom()` helpers for unit (reconciler.test.ts:32-50 + input-dispatcher.test.ts:13-29)
  - `vi.fn()` mocks + `mock.calls[N]` assertion style

  ## Per-test-file Pattern Assignments

  ### `apps/server/test/position-update.test.ts` (unit)
  **Analog:** `packages/protocol/test/intents.test.ts` lines 15-90 (zod accept/reject cases)
  **Apply:**
  - Mirror the 5 case-pattern: accepts new shape, accepts axis-edge, REJECTS legacy shape, REJECTS forged identity fields, REJECTS out-of-range.
  - Add stale-seq guard test (specific to 06.7).

  ### `apps/server/test/position-update.integ.test.ts` (integ)
  **Analog:** `apps/server/test/event-driven-input.integ.test.ts` lines 1-100
  **Apply:**
  - `joinClient()` helper (lines 35-53)
  - `positionOf(j)` accessor (lines 55-63) — same shape; read `x, y, anim_state, facing` from PlayerState map
  - two-client flow: client A sends position_update, await `positionOf(B's view of A)` matches

  ### `apps/server/test/sprite-override.test.ts` (handler unit)
  **Analog:** `apps/server/test/d58c-set-facing.integ.test.ts` lines 92-end (set_facing roundtrip)
  **Apply:** mirror set_facing assertion: send `set_sprite_override` with `sprite_id: 'hexport_in'`, assert `PlayerState.sprite_override === 'hexport_in'`.

  ### `apps/server/test/protocol-v4-handshake.integ.test.ts`
  **Analog:** `apps/server/test/protocol-v2-handshake.integ.test.ts` lines 1-68
  **Apply:**
  - `expect(PROTOCOL_VERSION).toBe(4);` (sanity)
  - v3 client receives close code 4400 with `PROTOCOL_VERSION_MISMATCH`
  - v4 client (current) succeeds
  - (Carry-forward the `client.joinOrCreate('rebno', { protocol_version: 3, session_token: 'dev-bypass' })` pattern at line 38.)

  ### `apps/client/src/__test__/position-dispatcher.test.ts`
  **Analog:** `apps/client/src/__test__/input-dispatcher.test.ts` lines 1-80
  **Apply:**
  - `makeMockRoom()` + `makeMockPrediction()` helpers (lines 13-29)
  - `vi.useFakeTimers()` setup/teardown (lines 36-46)
  - Per-call assertion: `room.send.mock.calls[0]![1]` for payload shape (line 52-58)
  - New cases: `sendTick(t)` emits payload matching cPositionUpdateSchema; seq monotonically increments; packAnimState produces correct byte for facing+running combo.

  ### `apps/client/src/__test__/reconciler.test.ts` (ADD case)
  **Analog:** existing same file lines 52-93 (lerp + hard-snap cases)
  **Apply (D-04 acceptance criterion):**
  ```typescript
  it('D-04 06.7 — onServerSnapshot is a NO-OP for self-player (no setPosition, no tweenTo)', () => {
    const m = mockAdapter(100, 100);
    const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
    // Server says (500, 500) — would have triggered hard snap pre-06.7.
    eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true);
    expect(m.setPosition).not.toHaveBeenCalled();
    expect(m.tweenTo).not.toHaveBeenCalled();
  });
  ```

  ### `packages/protocol/test/anim-state.test.ts` (NEW)
  **Analog:** `packages/protocol/test/intents.test.ts` lines 1-15 (header + import style)
  **Apply:**
  - Loop the 16 base poses; round-trip `packAnimState(facing, running)` → `unpackAnimState(byte)` returns same `{facing, running}`.
  - Bit-layout assertions: `packAnimState('D', false) === 0x06`, `packAnimState('DR', true) === 0x0F`, etc. (Reference: RESEARCH §anim_state byte layout table.)

  ### `packages/protocol/test/state.test.ts` (MODIFY)
  **Analog:** existing same file lines 10-30
  **Apply:**
  - Update version literal test: `expect(PROTOCOL_VERSION).toBe(4);`
  - Add defaults for new fields:
    ```typescript
    it('PlayerState defaults — anim_state starts at 0x06 (STAND_D), sprite_override = "none"', () => {
      const p = new PlayerState();
      expect(p.anim_state).toBe(0x06);
      expect(p.sprite_override).toBe('none');
    });
    ```

  ---

  ## CLAUDE.md Hard Rule 1 carve-out (doctrine — D-13)

  **Location:** `CLAUDE.md` §"Hard Rules" Rule 1

  **Current text (locate by Grep):**
  > 1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.

  **Replacement (D-13 — drop scores/combat; carve out movement):**
  > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8.

  Tag the paragraph: `[doc->REQ-SRV-04]` (or whichever REQ traces to Rule 1; verify in `traceable-reqs.toml`).

  ---

  ## `.planning/phases/06.7-.../06.7-HUMAN-UAT.md` (operator UAT script)

  **Analog:** `.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md`

  **Apply:** copy the structure (preconditions, step-by-step operator actions, expected vs observed columns, sign-off section). Cover RESEARCH §Validation row "REQ-CLI-08 manual-only":
  - Diagonal-stop drift bug closed (no ~10 px shift on stop)
  - Dropped-packet hitching closed (simulated 5% loss via Chrome DevTools network conditions — verify smooth visual)
  - Two-player smoke (A moves, B sees A move smoothly; no rubber-band)

  ---

  ## No Analog Found

  None. Every file has a clear precedent inside the repository — this phase is a pure refactor of established patterns. The `position_update` flow is structurally identical to `set_facing` (D-58c) scaled to 30 Hz with int16 fields; the `set_sprite_override` flow is `set_facing` with a different enum; tests mirror existing 06.4 / 06.6 patterns.

  ## Metadata

  **Analog search scope:**
  - `packages/protocol/src/` + `packages/protocol/test/`
  - `apps/server/src/` + `apps/server/test/`
  - `apps/client/src/prediction/` + `apps/client/src/net/` + `apps/client/src/__test__/`
  - `.planning/phases/06.*/` for HUMAN-UAT precedent

  **Files scanned (direct Read):** 11
  - intents.ts, state.ts, version.ts, onMessageHandlers.ts (full + targeted), RebnoRoom.ts (tickLoop region), reconciler.ts (full), input-dispatcher.ts (header + sendInput region), colyseus-client.ts (bindHandlers region), reconciler.test.ts (head), input-dispatcher.test.ts (head), intents.test.ts (head), state.test.ts (head), d58c-set-facing.integ.test.ts (head), protocol-v2-handshake.integ.test.ts (full), event-driven-input.integ.test.ts (head)

  **Pattern extraction date:** 2026-05-17

  ### Plans to Review

  ---
  #### 06.7-01-PLAN.md
  ---
  phase: 06.7
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - packages/protocol/src/intents.ts
    - packages/protocol/src/state.ts
    - packages/protocol/src/version.ts
    - packages/protocol/src/anim-state.ts
    - packages/protocol/src/index.ts
    - packages/protocol/test/anim-state.test.ts
    - packages/protocol/test/intents.test.ts
    - packages/protocol/test/state.test.ts
  autonomous: true
  requirements:
    - REQ-SRV-03
    - REQ-CLI-04
    - REQ-CLI-08
  must_haves:
    truths:
      - "Protocol exposes cPositionUpdateSchema accepting {type:'position_update', x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms} with int16 bounds + .strict()"
      - "Protocol exposes cSetSpriteOverrideSchema accepting {type:'set_sprite_override', sprite_id} as a closed enum"
      - "packAnimState(facing, isRunning) and unpackAnimState(byte) round-trip cleanly for all 16 base poses; STAND_D = 0x06, RUN_DR = 0x0F"
      - "PlayerState gains anim_state:number (default 0x06) and sprite_override:string (default 'none')"
      - "PROTOCOL_VERSION is the literal 4; history block records the 06.7 D-05/D-08/D-13 reason"
      - "c2sSchema discriminatedUnion includes cPositionUpdateSchema and cSetSpriteOverrideSchema"
    artifacts:
      - path: "packages/protocol/src/intents.ts"
        provides: "cPositionUpdateSchema + cSetSpriteOverrideSchema + CPositionUpdate/CSetSpriteOverride types"
        contains: "cPositionUpdateSchema"
      - path: "packages/protocol/src/anim-state.ts"
        provides: "FACING_OCTANT record, packAnimState(facing, isRunning), unpackAnimState(byte)"
        contains: "packAnimState"
      - path: "packages/protocol/src/state.ts"
        provides: "PlayerState with anim_state:number and sprite_override:string"
        contains: "anim_state"
      - path: "packages/protocol/src/version.ts"
        provides: "PROTOCOL_VERSION = 4"
        contains: "PROTOCOL_VERSION = 4"
      - path: "packages/protocol/test/anim-state.test.ts"
        provides: "Pack/unpack round-trip + bit-layout assertions"
        contains: "round-trip"
    key_links:
      - from: "packages/protocol/src/index.ts"
        to: "packages/protocol/src/anim-state.ts"
        via: "barrel export"
        pattern: "anim-state"
      - from: "packages/protocol/src/intents.ts"
        to: "c2sSchema discriminatedUnion"
        via: "cPositionUpdateSchema entry"
        pattern: "cPositionUpdateSchema"
      - from: "packages/protocol/src/intents.ts"
        to: "FACING_OCTANT"
        via: "import from anim-state.ts"
        pattern: "from './anim-state"
  ---

  <objective>
  Land the protocol surface that all downstream 06.7 work consumes: new c2s schemas (`cPositionUpdateSchema`, `cSetSpriteOverrideSchema`), the `packAnimState`/`unpackAnimState`/`FACING_OCTANT` helpers, additive `PlayerState` fields (`anim_state`, `sprite_override`), and the PROTOCOL_VERSION 3 → 4 bump. Plans 02 (server) and 03 (client) compile against this surface.

  Purpose: D-05 / D-08 / D-09 / D-10 / D-13 wire-shape decisions are realised here. Interface-first task ordering — define contracts before consumers exist.
  Output: New file `packages/protocol/src/anim-state.ts`; modified `intents.ts`, `state.ts`, `version.ts`, `index.ts`; tests covering round-trip + schema accept/reject + state defaults + version literal.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @packages/protocol/src/intents.ts
  @packages/protocol/src/state.ts
  @packages/protocol/src/version.ts
  @packages/protocol/src/index.ts
  @packages/protocol/test/intents.test.ts
  @packages/protocol/test/state.test.ts

  <interfaces>
  <!-- Authoritative contracts for downstream Plans 02 + 03. Executor should treat -->
  <!-- these as the spec; no codebase exploration required to know shape. -->

  cPositionUpdateSchema (NEW — packages/protocol/src/intents.ts) — per D-05/D-07/D-08/D-10 + RESEARCH §"Recommended position_update Wire Schema":
  - type: literal 'position_update'
  - x:  z.number().int().min(-32768).max(32767)
  - y:  z.number().int().min(-32768).max(32767)
  - vx: z.number().int().min(-32768).max(32767)
  - vy: z.number().int().min(-32768).max(32767)
  - facing: z.enum(['D','DR','R','UR','U','UL','L','DL'])
  - anim_state: z.number().int().min(0).max(255)
  - seq: z.number().int().min(0)
  - monotonic_at_ms: z.number().nonnegative()
  - .strict()

  cSetSpriteOverrideSchema (NEW) — per D-09 + RESEARCH §"set_sprite_override schema":
  - type: literal 'set_sprite_override'
  - sprite_id: z.enum(['hexport_in','hexport_out','tele_in','tele_out','ncol_1','ncol_2','ncol_3','ncol_4','jokershell','watching','none'])
  - .strict()

  FACING_OCTANT (NEW — packages/protocol/src/anim-state.ts):
  - R:0, UR:1, U:2, UL:3, L:4, DL:5, D:6, DR:7

  packAnimState(facing: Direction, isRunning: boolean): number
  - byte = FACING_OCTANT[facing] | (isRunning ? 0x08 : 0)
  unpackAnimState(byte: number): { facing: Direction; running: boolean }
  - octant = byte & 0x07; running = (byte & 0x08) !== 0; facing = inverse table

  PlayerState additive fields (state.ts) — per D-08 + RESEARCH §"Pitfall 5":
    @type('number') anim_state: number = 0x06;       // STAND_D default
    @type('string') sprite_override: string = 'none';

  PROTOCOL_VERSION literal change: `export const PROTOCOL_VERSION = 4 as const;`
  History block entry text — RESEARCH §"Bump pattern".
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: anim-state module + protocol barrel export + round-trip test</name>
    <read_first>
      - packages/protocol/src/index.ts (current barrel exports — confirm pattern for adding a new module)
      - packages/protocol/src/intents.ts (Direction type / facing enum origin — confirm where 'D','DR','R','UR','U','UL','L','DL' is canonically declared so anim-state.ts can import or re-declare consistently)
      - packages/protocol/test/intents.test.ts (test file header conventions, vitest imports, test-tagging style)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"anim_state byte layout (packed 1-byte enum)" and §"facing enum (octant index)" (source-of-truth byte values: STAND_D=0x06, RUN_DR=0x0F, all 16 base poses)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/test/anim-state.test.ts (NEW)"
    </read_first>
    <behavior>
      - packAnimState('D', false) === 0x06
      - packAnimState('DR', false) === 0x07
      - packAnimState('R', false) === 0x00
      - packAnimState('UR', false) === 0x01
      - packAnimState('U', false) === 0x02
      - packAnimState('UL', false) === 0x03
      - packAnimState('L', false) === 0x04
      - packAnimState('DL', false) === 0x05
      - packAnimState('R', true) === 0x08
      - packAnimState('DR', true) === 0x0F
      - packAnimState('D', true) === 0x0E
      - Round-trip: for all 8 facings × {true,false}, unpackAnimState(packAnimState(f, r)) deep-equals { facing: f, running: r }
      - unpackAnimState(0x06) deep-equals { facing: 'D', running: false }
      - unpackAnimState(0x0F) deep-equals { facing: 'DR', running: true }
      - FACING_OCTANT has all 8 keys mapped to the documented 0..7 values
    </behavior>
    <action>
      Create `packages/protocol/src/anim-state.ts` exporting (a) the `FACING_OCTANT` constant record with the 8 keys mapped to 0..7 per the table in RESEARCH §"facing enum (octant index)", (b) an `OCTANT_FACING` inverse lookup (string[8]) for unpack, (c) `packAnimState(facing, isRunning)` returning `FACING_OCTANT[facing] | (isRunning ? 0x08 : 0)`, (d) `unpackAnimState(byte)` returning `{ facing: OCTANT_FACING[byte & 0x07], running: (byte & 0x08) !== 0 }`. The `Direction` type alias is imported from `./intents.ts` if it already exists there; if not, declare a local `Direction = 'D' | 'DR' | 'R' | 'UR' | 'U' | 'UL' | 'L' | 'DL'` and re-export it from anim-state.ts so other modules (PositionDispatcher in Plan 03) can import a single canonical name. Tag the module header `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]`. Add a comment block referencing RESEARCH §"anim_state byte layout" with the full byte-table for future reference.

      Update `packages/protocol/src/index.ts` to re-export the new module: add `export * from './anim-state.js';` near the existing barrel exports.

      Create `packages/protocol/test/anim-state.test.ts`. Follow the file-header tagging style from `packages/protocol/test/intents.test.ts:1-3` with `// [unit->REQ-CLI-04] [unit->REQ-SRV-03]`. Import vitest `describe / it / expect`. Cover the 13 behaviors enumerated above (8 stand bytes + 2-3 named run bytes + round-trip loop + FACING_OCTANT keys). Use a for-loop to iterate all 8 facings × {true,false} for the round-trip; assert per-iteration with `expect(unpackAnimState(packAnimState(f, r))).toEqual({ facing: f, running: r })`.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/anim-state.test.ts</automated>
    </verify>
    <done>anim-state.ts compiles, exports the three named symbols + Direction + OCTANT_FACING; round-trip test green; barrel exports the module so `import { packAnimState } from '@rebno/protocol'` resolves.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "export.*packAnimState" packages/protocol/src/anim-state.ts` matches at least one line
      - Source assertion: `grep -n "FACING_OCTANT" packages/protocol/src/anim-state.ts` matches at least one line
      - Source assertion: `grep -n "from './anim-state" packages/protocol/src/index.ts` matches (barrel updated)
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/anim-state.test.ts` exits 0 and reports the round-trip loop (16 cases) plus the named-byte assertions all passing
      - Tag assertion: `grep -n "impl->REQ-CLI-04" packages/protocol/src/anim-state.ts` matches at least one line
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: cPositionUpdateSchema + cSetSpriteOverrideSchema + discriminated-union registration + intents tests</name>
    <read_first>
      - packages/protocol/src/intents.ts (entire file — cSetFacingSchema @ lines 93-98, cInputSchema @ 45-59, c2sSchema discriminated-union @ 120-127, type-export pattern @ 129-135 are the analogs)
      - packages/protocol/test/intents.test.ts (existing accept/reject test pattern)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/src/intents.ts" (exact analog locations)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Recommended position_update Wire Schema" + §"set_sprite_override schema (rare event)"
    </read_first>
    <behavior>
      - cPositionUpdateSchema.safeParse(valid 18 B payload) returns success=true
      - cPositionUpdateSchema rejects x = 32768 (out of int16 range)
      - cPositionUpdateSchema rejects x = -32769
      - cPositionUpdateSchema rejects facing = 'X' (not in enum)
      - cPositionUpdateSchema rejects anim_state = 256 (out of u8 range)
      - cPositionUpdateSchema rejects anim_state = -1
      - cPositionUpdateSchema rejects seq = -1
      - cPositionUpdateSchema rejects monotonic_at_ms = -1
      - cPositionUpdateSchema rejects extra fields per .strict() (e.g. `account_id: 'attacker'`)
      - cPositionUpdateSchema rejects missing required field (e.g. drop `seq`)
      - cSetSpriteOverrideSchema accepts every documented sprite_id including 'none'
      - cSetSpriteOverrideSchema rejects sprite_id = 'unknown'
      - cSetSpriteOverrideSchema rejects extra fields (.strict)
      - c2sSchema.parse({ type:'position_update', ... }) routes to cPositionUpdateSchema
      - c2sSchema.parse({ type:'set_sprite_override', sprite_id:'hexport_in' }) routes correctly
    </behavior>
    <action>
      In `packages/protocol/src/intents.ts`:
      - Add `cPositionUpdateSchema` after `cSetFacingSchema`. Field-by-field per the `<interfaces>` block above. Use `.strict()`. Tag the export line with `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` per the precedent at intents.ts:83.
      - Add `cSetSpriteOverrideSchema` immediately after `cPositionUpdateSchema`. Use `.strict()`. Same tagging.
      - Extend the existing `c2sSchema = z.discriminatedUnion('type', [...])` array (lines 120-127 today) by appending `cPositionUpdateSchema` and `cSetSpriteOverrideSchema`. KEEP `cInputSchema` in the union per RESEARCH §"Adding cPositionUpdateSchema to the discriminated-union" (pauseMovement contract + test harness path).
      - Export TS types: `export type CPositionUpdate = z.infer<typeof cPositionUpdateSchema>;` and `export type CSetSpriteOverride = z.infer<typeof cSetSpriteOverrideSchema>;` near the existing `CSetFacing` export at lines 129-135.

      Update `packages/protocol/test/intents.test.ts`:
      - Add a `describe('cPositionUpdateSchema (06.7 D-05)', ...)` block. Each `it` covers one of the 10 position_update behaviors enumerated above. Use the existing accept/reject style (`expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true/false)`).
      - Add a `describe('cSetSpriteOverrideSchema (06.7 D-09)', ...)` block covering the 3 override behaviors.
      - Add 2 `it` cases under an existing or new `describe('c2sSchema discriminated-union')` block: position_update routing + set_sprite_override routing.

      Do NOT yet touch `state.ts` or `version.ts` — those are Task 3.

      Tags on each new `it` body: `// [unit->REQ-SRV-03]` for handler-side intent validation; `// [unit->REQ-CLI-04]` where the field originates client-side.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/intents.test.ts</automated>
    </verify>
    <done>Both new schemas exported and reachable from `@rebno/protocol` consumers; discriminated-union dispatches by `type` correctly; all new tests pass; existing intents tests still green.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "cPositionUpdateSchema" packages/protocol/src/intents.ts` matches export + union-array references
      - Source assertion: `grep -n "cSetSpriteOverrideSchema" packages/protocol/src/intents.ts` matches export + union-array references
      - Source assertion: `grep -n "\.strict()" packages/protocol/src/intents.ts | grep -E "(cPositionUpdateSchema|cSetSpriteOverrideSchema)" -B1` shows .strict() on both new schemas (or, simpler, the new schema definitions in the file end with `.strict()` — visually confirm via file diff)
      - Source assertion: `grep -c "cInputSchema" packages/protocol/src/intents.ts` returns ≥ 2 (still present after the union edit)
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/intents.test.ts` exits 0; the new describes report at least 14 passing assertions total (10 position_update + 3 set_sprite_override + 2 c2s union routes — count is a floor, more is fine)
      - Tag assertion: `grep -n "impl->REQ-CLI-04" packages/protocol/src/intents.ts` matches at least one line in the new region
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: PlayerState anim_state + sprite_override fields + PROTOCOL_VERSION bump 3 → 4 + state.test updates</name>
    <read_first>
      - packages/protocol/src/state.ts (full file — `facing` field @ line 52 is the additive analog; the wire-version contract comment block @ lines 33-51 documents the additive convention)
      - packages/protocol/src/version.ts (full file — history block @ lines 14-22 + literal @ line 24)
      - packages/protocol/test/state.test.ts (existing version-literal + defaults tests @ lines 10-30)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/src/state.ts" + §"packages/protocol/src/version.ts" (exact text for the history-block append)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pitfall 5" (PlayerState extension discipline)
    </read_first>
    <behavior>
      - `new PlayerState().anim_state === 0x06`
      - `new PlayerState().sprite_override === 'none'`
      - `PROTOCOL_VERSION === 4`
      - Existing PlayerState defaults tests still pass (facing default unchanged)
    </behavior>
    <action>
      In `packages/protocol/src/state.ts`:
      - Add two `@type(...)` decorated fields on `PlayerState`, placed immediately after the existing `facing` field at line 52 to mirror the 06.4 D-58c precedent:
        ```
        @type('number') anim_state: number = 0x06;
        @type('string') sprite_override: string = 'none';
        ```
      - Extend the wire-version contract comment block above the class (lines 33-51 region) with a 06.7 entry noting: ADDITIVE (still wire-compatible with v3 state-diff per Pitfall 5), default 0x06 = STAND_D, default 'none' clears any override, populated by the new c2s.position_update / c2s.set_sprite_override handlers added in Plan 02.
      - Do NOT change `last_input_seq`, `vx`, `vy`, `facing` (already present and reused).

      In `packages/protocol/src/version.ts`:
      - Change `export const PROTOCOL_VERSION = 3 as const;` to `export const PROTOCOL_VERSION = 4 as const;` (existing literal at line 24).
      - Append a new history entry under the v3 line, matching the exact wording in PATTERNS.md §"version.ts" block:
        ```
        //  - 4: Phase 06.7 D-05/D-08/D-13 — flips player movement to CLIENT-AUTHORITATIVE
        //       absolute-position streaming. NEW c2s.position_update intent (30 Hz, int16
        //       quantized x/y/vx/vy + facing + anim_state + seq + monotonic_at_ms). NEW
        //       c2s.set_sprite_override event-only intent. PlayerState gains anim_state
        //       (u8 packed) + sprite_override (string). v3 clients close 4400 / MISMATCH.
        ```

      Update `packages/protocol/test/state.test.ts`:
      - Update the existing PROTOCOL_VERSION literal test (lines 10-30 region) to assert `expect(PROTOCOL_VERSION).toBe(4);`.
      - Add an `it` case asserting the two new defaults:
        `expect(new PlayerState().anim_state).toBe(0x06); expect(new PlayerState().sprite_override).toBe('none');`
      - Tag the modified file header with `// [unit->REQ-SRV-03] [unit->REQ-CLI-04]` if not already present.

      Note: this task lands the version bump but does NOT yet add the protocol-v4-handshake integration test — that lives in Plan 02 alongside server changes that produce the rejection path.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/state.test.ts && pnpm exec tsc --noEmit</automated>
    </verify>
    <done>PROTOCOL_VERSION literal = 4; PlayerState defaults match D-08 + D-09; existing facing default test still green; pnpm trace:check passes for protocol package.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "PROTOCOL_VERSION = 4 as const" packages/protocol/src/version.ts` matches exactly once
      - Source assertion: `grep -nE "^//  - 4: Phase 06.7" packages/protocol/src/version.ts` matches the new history line
      - Source assertion: `grep -n "anim_state: number = 0x06" packages/protocol/src/state.ts` matches
      - Source assertion: `grep -n "sprite_override: string = 'none'" packages/protocol/src/state.ts` matches
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/state.test.ts` exits 0; the PROTOCOL_VERSION test asserts 4; the new defaults `it` passes
      - Build assertion: `pnpm -C packages/protocol exec tsc --noEmit` exits 0
      - Trace assertion: `pnpm trace:check` does not regress on REQ-SRV-03 / REQ-CLI-04 doc stages (does not fail the build)
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server (`Room.send`) | Untrusted JSON crosses into the room handler; zod is the gate |
  | protocol package consumers | Schema exports must be `.strict()` to deny extra fields by default |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-01a | Tampering | `cPositionUpdateSchema` field bounds | mitigate | Per D-11, server-side SEMANTIC validation of position legality is OUT OF SCOPE — relegated to candidate Phase 06.8. Defense-in-depth at the protocol boundary is still enforced: int16 bounds on x/y/vx/vy (caps malicious unbounded ints), u8 bound on anim_state, enum membership on facing, non-negative on seq + monotonic_at_ms. Implemented via zod `z.number().int().min(-32768).max(32767)` etc.; rejection happens at handler `safeParse` before any state write. |
  | T-06.7-02 | Tampering / Spoofing | `cSetSpriteOverrideSchema` sprite_id | mitigate | `z.enum([...])` is a closed set; `.strict()` rejects extras. Any non-listed sprite_id is rejected at handler-side `safeParse` (Plan 02). |
  | T-06.7-04 | Spoofing | wire-side identity fields (e.g. attacker submits `{ account_id: 'victim' }` on position_update) | mitigate | `.strict()` on every new schema rejects EVERY extra field; server consumes identity from `client.auth.account_id`, never wire. Confirmed by intents.test.ts case "REJECTS forged identity fields" (Task 2). |
  | T-06.7-05 | DoS | giant nested payloads | mitigate | int16 bounds + 1-byte enum + bounded enum strings + non-negative integers — every field is finite, so memory cost per parse is constant. No string-without-length, no deep nesting allowed. |

  **Doctrine change note (D-13):** This plan does NOT modify CLAUDE.md — the Hard Rule 1 carve-out edit lives in Plan 04. But the schemas defined here ARE the substrate that the doctrine change describes: the client-trust model is implemented in protocol shape before doctrine is updated. Plan 04 makes the rule explicit.
  </threat_model>

  <verification>
  - `pnpm -C packages/protocol exec vitest run` green across all protocol test files (intents, state, anim-state, events, version)
  - `pnpm -C packages/protocol exec tsc --noEmit` clean
  - `pnpm trace:check` does not regress
  - `grep -c "PROTOCOL_VERSION = 4" packages/protocol/src/version.ts` returns exactly 1
  - `grep -c "cPositionUpdateSchema" packages/protocol/src/intents.ts | grep -v '^#'` returns ≥ 3 (definition + type infer + discriminated-union entry)
  </verification>

  <success_criteria>
  - All three tasks land their files; tests for each green
  - Downstream Plans 02 + 03 can `import { cPositionUpdateSchema, cSetSpriteOverrideSchema, packAnimState, unpackAnimState, FACING_OCTANT, type CPositionUpdate, type CSetSpriteOverride } from '@rebno/protocol'` with no codebase exploration
  - PROTOCOL_VERSION = 4 visible from any consumer (handshake-rejection test in Plan 02 imports it directly)
  - No regression to existing intents/state tests
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-SUMMARY.md` per the template — call out what consumers can import, the byte-layout reference for future protocol readers, and the v3→v4 history entry for archival.
  </output>

  ---
  #### 06.7-02-PLAN.md
  ---
  phase: 06.7
  plan: 02
  type: execute
  wave: 2
  depends_on:
    - 06.7-01
  files_modified:
    - apps/server/src/onMessageHandlers.ts
    - apps/server/src/RebnoRoom.ts
    - packages/game-logic/src/step.ts
    - apps/server/test/position-update.test.ts
    - apps/server/test/position-update.integ.test.ts
    - apps/server/test/sprite-override.test.ts
    - apps/server/test/protocol-v4-handshake.integ.test.ts
    - packages/game-logic/test/step.test.ts
  autonomous: true
  requirements:
    - REQ-SRV-03
    - REQ-SRV-14
    - REQ-CLI-08
  must_haves:
    truths:
      - "Server accepts c2s.position_update from an authed client; payload mutates that client's PlayerState (x, y, vx, vy, facing, anim_state, last_input_seq)"
      - "Server accepts c2s.set_sprite_override from an authed client; payload mutates that client's PlayerState.sprite_override"
      - "Server rate-limits position_update per (account_id, msg_type='position_update') via the existing token bucket (~35 tokens/s, burst 60)"
      - "Server rejects out-of-order position_update by seq <= last_input_seq"
      - "Server tickLoop runs step() with an empty inputs Map — player position is NOT derived from heldInputs"
      - "Server step() still advances platform positions per (REQ-SRV-14)"
      - "PROTOCOL_VERSION 3 client is rejected at handshake (close code 4400 / PROTOCOL_VERSION_MISMATCH); v4 client succeeds"
      - "Two-client integ: client A sends position_update → B observes PlayerState diff for A within one broadcast cycle (~50ms)"
    artifacts:
      - path: "apps/server/src/onMessageHandlers.ts"
        provides: "position_update + set_sprite_override Colyseus onMessage handlers"
        contains: "position_update"
      - path: "apps/server/src/RebnoRoom.ts"
        provides: "tickLoop with empty-inputs Map; step() runs platforms only"
        contains: "new Map()"
      - path: "packages/game-logic/src/step.ts"
        provides: "Players pass-through; platform advance only"
        contains: "REQ-SRV-14"
      - path: "apps/server/test/position-update.test.ts"
        provides: "Unit tests for zod schema rejection + handler PlayerState writes + stale-seq guard"
        contains: "describe"
      - path: "apps/server/test/position-update.integ.test.ts"
        provides: "Two-client roundtrip integration test"
        contains: "joinClient"
      - path: "apps/server/test/sprite-override.test.ts"
        provides: "Handler unit + schema acceptance test"
        contains: "set_sprite_override"
      - path: "apps/server/test/protocol-v4-handshake.integ.test.ts"
        provides: "v3 client rejected; v4 client accepted at handshake"
        contains: "PROTOCOL_VERSION_MISMATCH"
    key_links:
      - from: "apps/server/src/onMessageHandlers.ts"
        to: "PlayerState.{x,y,vx,vy,facing,anim_state,last_input_seq,sprite_override}"
        via: "Direct field writes after zod safeParse + auth + rate-limit"
        pattern: "player\\.(x|y|vx|vy|facing|anim_state|last_input_seq|sprite_override)\\s*="
      - from: "apps/server/src/RebnoRoom.ts tickLoop"
        to: "step(toWorldState(), new Map(), TICK_MS)"
        via: "Empty inputs Map (was heldInputs-derived)"
        pattern: "step\\(.*new Map\\(\\)"
      - from: "packages/game-logic/src/step.ts"
        to: "platforms advance loop only (player pass-through)"
        via: "input-axis decoding block removed for players; platforms unchanged"
        pattern: "newPlatforms"
  ---

  <objective>
  Wire the server side of the client-trust flip: add `position_update` and `set_sprite_override` Colyseus handlers; modify the room tickLoop to stop deriving player position from `heldInputs`; trim `step()`'s player branch to a pass-through while preserving the platforms branch (REQ-SRV-14); ship integration tests covering the two-client smoke + protocol v3 rejection.

  Purpose: D-05 / D-06 / D-08 / D-09 / D-11 land server-side here. After this plan, an authed client streaming valid `position_update` messages becomes the authoritative source of its own x/y/vx/vy/facing/anim_state; the server stores and rebroadcasts via the existing Colyseus state-diff channel.
  Output: 2 modified source files (handlers + tick loop) + 1 modified game-logic file + 4 new test files (unit, two-client integ, override unit, v4 handshake integ).
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-PLAN.md
  @apps/server/src/onMessageHandlers.ts
  @apps/server/src/RebnoRoom.ts
  @packages/game-logic/src/step.ts
  @apps/server/test/d58c-set-facing.integ.test.ts
  @apps/server/test/event-driven-input.integ.test.ts
  @apps/server/test/protocol-v2-handshake.integ.test.ts

  <interfaces>
  <!-- All consumed from Plan 01. No codebase exploration required. -->

  From @rebno/protocol (Plan 01 deliverable):
    cPositionUpdateSchema    — zod object, .strict(), 9 fields
    cSetSpriteOverrideSchema — zod object, .strict(), { type, sprite_id }
    type CPositionUpdate, type CSetSpriteOverride
    PROTOCOL_VERSION = 4 (literal)
    PlayerState (Colyseus schema) gains:
      anim_state: number = 0x06
      sprite_override: string = 'none'
    Existing fields (pre-06.7): x, y, vx, vy, facing, last_input_seq, account_id, axis_x_held, axis_y_held — UNCHANGED

  Existing server scaffolding (DO NOT redefine — extend/reuse):
    onMessageHandlers.ts § lines 69-96 — `rateLimitOrDrop(ctx, client, msg_type, account_id)` token bucket
    onMessageHandlers.ts § lines 107-110 — `ctx.getAuth(client.sessionId)` resolves auth.account_id
    onMessageHandlers.ts § set_facing handler @ lines 264-301 — analog pattern (auth → rate-limit → zod → state write)
    RebnoRoom.ts § tickLoop @ lines 1274-1315 — current heldInputs/inputBuffer loop
    step.ts § player input application — block being trimmed (per RESEARCH §Pattern 2)
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: position_update + set_sprite_override handlers in onMessageHandlers.ts + handler unit tests</name>
    <read_first>
      - apps/server/src/onMessageHandlers.ts (FULL — set_facing handler @ lines 264-301 is the analog; rate-limit gate @ 69-96; auth resolution @ 107-110; msg_type rate-limit union @ line 72; import block @ 25-33)
      - apps/server/test/d58c-set-facing.integ.test.ts (entire file — test scaffolding: spawnServer, joinAs, vitest pattern)
      - packages/protocol/src/intents.ts (Plan 01 added cPositionUpdateSchema + cSetSpriteOverrideSchema — verify exports compile)
      - packages/protocol/src/state.ts (Plan 01 added anim_state + sprite_override PlayerState fields)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/server/src/onMessageHandlers.ts" §"Shared Patterns" (rate-limit, auth, zod .strict(), server-tagged identity, tag-comment)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 1" §"Pitfall 3" (rate-limit budget 35 tokens/sec / burst 60) §"Pattern 4" (stale-seq guard)
    </read_first>
    <behavior>
      Unit (apps/server/test/position-update.test.ts):
      - Handler with valid payload writes player.x, player.y, player.vx, player.vy, player.facing, player.anim_state, player.last_input_seq, leaves player.sprite_override unchanged
      - Handler rejects when no auth resolved (no PlayerState mutation)
      - Handler rejects when rate-limit returns false (rateLimitOrDrop dropped)
      - Handler rejects malformed payload (zod safeParse failure) — no mutation
      - Handler with stale seq (seq <= player.last_input_seq) returns early — no mutation, no last_input_seq advance
      - Handler with monotonic seq advances last_input_seq and writes new x/y
      - Handler with player not in state.players (e.g. mid-leave race) returns without throw

      Unit (apps/server/test/sprite-override.test.ts):
      - Handler with valid sprite_id='hexport_in' writes player.sprite_override = 'hexport_in'
      - Handler with sprite_id='none' clears the override
      - Handler rejects no-auth / invalid sprite_id / rate-limited cases analogously
    </behavior>
    <action>
      In `apps/server/src/onMessageHandlers.ts`:
      - Extend the `msg_type` union literal (line ~72) to include `'position_update'` and `'set_sprite_override'`.
      - Extend the import block (lines ~25-33) to add `cPositionUpdateSchema, cSetSpriteOverrideSchema` from `@rebno/protocol`.
      - Add the `position_update` handler immediately after the existing `set_facing` handler (the analog at lines 264-301). Implementation order per RESEARCH §Pattern 1 + PATTERNS.md "Shared Patterns":
        1. Resolve `auth = ctx.getAuth(client.sessionId)`; if missing, `log.warn` and return.
        2. `if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;`
        3. `const parsed = cPositionUpdateSchema.safeParse(raw);` — on `!parsed.success`, log warn with msg_type/sessionId/issue and return.
        4. Resolve the player via the same pattern as set_facing: `const player = ctx.room.state?.players?.get?.(client.sessionId);` typed via the local interface trick at lines 152-154; if missing, `log.warn(..., 'position_update_no_player_state')` and return.
        5. **Stale-seq guard:** `if (parsed.data.seq <= player.last_input_seq) return;` (do NOT advance last_input_seq).
        6. Write: `player.last_input_seq = parsed.data.seq; player.x = parsed.data.x; player.y = parsed.data.y; player.vx = parsed.data.vx; player.vy = parsed.data.vy; player.facing = parsed.data.facing; player.anim_state = parsed.data.anim_state;`
        7. Do NOT touch `player.sprite_override` — that's the other handler's domain.
      - Tag the handler block: `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` directly above `ctx.room.onMessage('position_update', ...)` per the precedent at lines 246-247.

      Add the `set_sprite_override` handler immediately after `position_update`. Implementation per RESEARCH §"set_sprite_override schema" + PATTERNS.md "sprite_override" pattern:
      - Same auth → rate-limit → zod → player-resolve flow.
      - On success: `player.sprite_override = parsed.data.sprite_id;` (the string enum value — 'none' is a legitimate clear).
      - No stale-seq guard (no seq field; it's an event-only intent per D-09).
      - Tag: `// [impl->REQ-SRV-03] [impl->REQ-CLI-04]`.

      Create `apps/server/test/position-update.test.ts`. Header tags `// [unit->REQ-SRV-03] [unit->REQ-CLI-04]`. Mirror the test scaffolding from `apps/server/test/d58c-set-facing.integ.test.ts` but UNIT-level (no spawnServer needed — call the handler directly with mocked `ctx`, `client`, `raw`, similar to mock-style server unit tests if any exist; otherwise structure as a thin integ that boots a single-client room). Cover the 7 unit behaviors enumerated above. The stale-seq case is critical (RESEARCH §Pattern 4) — assert that BOTH `player.x` is unchanged AND `player.last_input_seq` is unchanged when seq <= existing.

      Create `apps/server/test/sprite-override.test.ts`. Header tags `// [unit->REQ-SRV-03]`. Cover the override behaviors. Mirror the d58c-set-facing.integ.test.ts assertion shape: send the intent, await the next tick boundary or use `vi.waitFor` against `PlayerState.sprite_override`.
    </action>
    <verify>
      <automated>cd apps/server && pnpm exec vitest run test/position-update.test.ts test/sprite-override.test.ts</automated>
    </verify>
    <done>Both handlers compile against PlayerState's new fields; rate-limit msg_type extended; safeParse rejection paths and stale-seq guard verified; unit tests green.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "onMessage('position_update'" apps/server/src/onMessageHandlers.ts` matches exactly one line
      - Source assertion: `grep -n "onMessage('set_sprite_override'" apps/server/src/onMessageHandlers.ts` matches exactly one line
      - Source assertion: `grep -n "'position_update'" apps/server/src/onMessageHandlers.ts | grep -c "rateLimitOrDrop"` returns ≥ 1 (rate-limit gate is wired)
      - Source assertion: `grep -nE "if \(parsed\.data\.seq <= player\.last_input_seq\)" apps/server/src/onMessageHandlers.ts` matches the stale-seq guard
      - Source assertion: `grep -c "impl->REQ-SRV-03" apps/server/src/onMessageHandlers.ts` returns a value larger than its pre-06.7 count (the new handler tags landed) — record baseline with `git show HEAD:apps/server/src/onMessageHandlers.ts | grep -c "impl->REQ-SRV-03"` and assert strictly greater
      - Source assertion: rate-limit msg_type union (line ~72) contains both `'position_update'` and `'set_sprite_override'` — `grep -n "msg_type" apps/server/src/onMessageHandlers.ts | grep -c "position_update"` ≥ 1
      - Behavior assertion: `pnpm -C apps/server exec vitest run test/position-update.test.ts test/sprite-override.test.ts` exits 0 with ≥ 9 passing tests (7 + 2 floor)
      - Behavior assertion: existing handler tests do not regress — `pnpm -C apps/server exec vitest run test/d58c-set-facing.integ.test.ts` exits 0
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: RebnoRoom tickLoop empty-inputs + step.ts player pass-through + step regression test</name>
    <read_first>
      - apps/server/src/RebnoRoom.ts (tickLoop region @ lines 1274-1315 — the heldInputs / inputBuffer / step() loop is the surgical target)
      - packages/game-logic/src/step.ts (FULL — the player-input-application block is what becomes pass-through; the platform-advance loop is what's preserved)
      - packages/game-logic/test/step.test.ts (existing tests to preserve as a regression baseline + add a platform-advance assertion if missing)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/server/src/RebnoRoom.ts" + §"step.ts pass-through" (target pattern)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 2" §"Server tickLoop after the change" §"Server-side step() retention" §"Open Questions item 3" (heldInputs vestigial — leave for 06.9 cleanup)
    </read_first>
    <behavior>
      - step({players: { p1: {x:100, y:100, vx:0, vy:0, ...}, ... }, platforms: [...], ...}, new Map() /* empty inputs */, TICK_MS) returns a new state where p1.x === 100 and p1.y === 100 (UNCHANGED — player pass-through)
      - step({platforms: [{x:0, y:0, vx:1, vy:0, cycle_phase:0}]}, new Map(), TICK_MS=50) returns platforms[0].x = 0 + 1*50 = 50 (PLATFORMS UNCHANGED — REQ-SRV-14)
      - step({platforms: [{x:0, y:0, vx:1, vy:0, cycle_phase:0}]}, new Map(), TICK_MS=50) advances cycle_phase by 50
      - tickLoop in RebnoRoom invokes step() with an empty Map (not a heldInputs-derived map)
      - heldInputs writes from cInputSchema handler are NOT touched (vestigial per Open Question 3)
    </behavior>
    <action>
      In `apps/server/src/RebnoRoom.ts` tickLoop (lines 1274-1315):
      - DELETE the inner for-loop that copies heldInputs into the `inputs` Map (lines 1289-1300 region per PATTERNS.md "Current player-input application pattern (TO REMOVE for self-player)").
      - REPLACE the `step(this.toWorldState(), inputs, TICK_MS)` call with `step(this.toWorldState(), new Map(), TICK_MS)` and a comment block citing REQ-SRV-14 + D-01:
        `// 06.7 D-01/D-05 — Player position is now written by the position_update`
        `// handler (onMessageHandlers.ts). step() runs ONLY for platforms`
        `// (REQ-SRV-14). Empty inputs Map: with the step.ts pass-through change,`
        `// players are round-tripped unchanged.`
      - Do NOT delete the `inputBuffer` writes / `heldInputs` writes — left vestigial per Open Question 3 / 06.9 cleanup. Add a TODO comment near the heldInputs declaration: `// TODO(06.9): vestigial post-06.7 — see CONTEXT D-01 / Open Question 3.`

      In `packages/game-logic/src/step.ts`:
      - In the player-input-application block (the loop that reads `inputs.get(account_id)` and applies axes to player vx/vy), GATE the per-player input application on `inputs.size > 0`. When `inputs` is empty (the new server case from 06.7), the players Map round-trips with whatever vx/vy the handler last wrote. Equivalent acceptable patterns: (a) early-skip the input-application loop when `inputs.size === 0`; (b) keep the loop but make each iteration a no-op when `inputs.get(account_id)` returns undefined.
      - Tag the changed region: `// [impl->REQ-SRV-14] — platforms still advance; players are`
        `// pass-through when inputs is empty (06.7 client-trust model).`
      - DO NOT remove the input-application code path entirely. Client-side `predictor.ts` STILL calls `step()` with non-empty inputs for local prediction (RESEARCH §"Pattern 2" "Important nuance"). Both call sites must keep working — server passes empty, client passes its keyboard-derived axes.

      Update `packages/game-logic/test/step.test.ts`:
      - Add (if not already present) an `it` case asserting platform advance: build a state with one platform { x:0, y:0, vx:1, vy:0, cycle_phase:0 } and empty players + empty inputs; call `step(state, new Map(), 50)`; assert `result.platforms[0].x === 50` and `result.platforms[0].cycle_phase === 50`.
      - Add an `it` case asserting player pass-through: build a state with one player at (100, 100, vx:0, vy:0) and empty inputs; call `step(state, new Map(), 50)`; assert `result.players[0].x === 100 && result.players[0].y === 100`.
      - Tag the new cases `// [unit->REQ-SRV-14] [unit->REQ-SRV-03]`.

      KEY NOTE: existing step.test.ts cases that pass non-empty inputs must STILL pass (client-side prediction path is unchanged). Run the full file at the end to confirm.
    </action>
    <verify>
      <automated>cd packages/game-logic && pnpm exec vitest run test/step.test.ts && cd ../../apps/server && pnpm exec tsc --noEmit</automated>
    </verify>
    <done>Server tickLoop calls step with empty Map; step.ts gates input application on inputs.size; all existing step tests still pass; new pass-through + platform-advance regression cases pass; server typecheck clean.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "step\(this\.toWorldState\(\), new Map\(\)" apps/server/src/RebnoRoom.ts` matches exactly one line inside tickLoop
      - Source assertion: tickLoop NO LONGER copies `heldInputs` into the per-tick inputs Map — `grep -nE "for \(const \[account_id, held\] of this\.heldInputs\)" apps/server/src/RebnoRoom.ts` returns zero matches (the copy loop is removed)
      - Source assertion: `grep -nE "inputs\.size === 0|inputs\.size > 0" packages/game-logic/src/step.ts` matches at least one line (the gate is wired)
      - Source assertion: `grep -n "REQ-SRV-14" packages/game-logic/src/step.ts` count increases vs. pre-06.7 baseline (new tag landed)
      - Source assertion: `grep -n "TODO(06.9)" apps/server/src/RebnoRoom.ts` matches the heldInputs vestigial note
      - Behavior assertion: `pnpm -C packages/game-logic exec vitest run test/step.test.ts` exits 0 — all existing tests green PLUS the two new regression cases (pass-through + platform-advance) pass
      - Build assertion: `pnpm -C apps/server exec tsc --noEmit` exits 0
      - Regression assertion: `pnpm -C packages/game-logic exec vitest run` (full game-logic suite) exits 0
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Two-client position_update integ + protocol-v4-handshake integ</name>
    <read_first>
      - apps/server/test/event-driven-input.integ.test.ts (FULL — analog for two-client integ; joinClient helper, positionOf accessor)
      - apps/server/test/protocol-v2-handshake.integ.test.ts (FULL — analog for handshake-rejection integ; close-code 4400 / PROTOCOL_VERSION_MISMATCH assertion; joinOrCreate `protocol_version` option)
      - apps/server/test/d58c-set-facing.integ.test.ts (FULL — two-client setup pattern reference)
      - apps/server/test/test-utils.* (spawnServer / joinAs helpers — used by the analog tests)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"position-update.integ.test.ts" §"protocol-v4-handshake.integ.test.ts"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Validation Architecture" + §"Pitfall 4"
      - packages/protocol/src/version.ts (Plan 01 set PROTOCOL_VERSION=4)
    </read_first>
    <behavior>
      apps/server/test/position-update.integ.test.ts:
      - Client A joins, client B joins; both authed
      - Client A sends position_update {x:500, y:400, vx:5, vy:0, facing:'R', anim_state:0x08 /* RUN_R */, seq:1, monotonic_at_ms:1000}
      - Within the next setPatchRate cycle (~50ms; use vi.waitFor with 500ms budget), client B's view of PlayerState[A].x equals 500 and y equals 400 and anim_state equals 0x08 and facing equals 'R'
      - Client A sends a SECOND position_update with seq=1 (duplicate); server discards (player.last_input_seq stays at 1, no extra state churn observable)
      - Client A sends position_update with seq=2 and new coords; B observes them
      - Client A sends set_sprite_override {sprite_id:'hexport_in'}; B observes PlayerState[A].sprite_override === 'hexport_in'

      apps/server/test/protocol-v4-handshake.integ.test.ts:
      - `expect(PROTOCOL_VERSION).toBe(4);` sanity
      - A client joining with `joinOrCreate('rebno', { protocol_version: 3, session_token: 'dev-bypass' })` receives the close-code 4400 / PROTOCOL_VERSION_MISMATCH error (mirroring protocol-v2-handshake.integ.test.ts)
      - A client joining with `protocol_version: 4` succeeds (no close, joined state observable)
    </behavior>
    <action>
      Create `apps/server/test/position-update.integ.test.ts`:
      - Header tags `// [int->REQ-SRV-03] [int->REQ-CLI-04] [int->REQ-CLI-08]`.
      - Import vitest + spawnServer + joinAs/joinClient helpers from `./test-utils.js` per the analog files.
      - `describe('position_update integration (06.7)', () => { ... })`.
      - `beforeAll` spawns server; `afterAll` shuts down. Per-test `beforeEach` spawns fresh room if helper supports it; otherwise scope to one shared room.
      - Use the `positionOf(roomB, account_id)` accessor shape from event-driven-input.integ.test.ts:55-63 to read remote-player PlayerState from client B's view.
      - Cover the 5 integ behaviors enumerated above. Use `vi.waitFor` with a 500ms budget for the post-broadcast read (Colyseus setPatchRate is 50ms — 10x margin is safe in CI).
      - For the duplicate-seq case: after sending the second seq=1 update, wait one full patch cycle, then assert `roomB.state.players.get(aSessionId).last_input_seq === 1` (not 0, not 2 — discarded but the first one is still tracked).

      Create `apps/server/test/protocol-v4-handshake.integ.test.ts`:
      - Header tags `// [int->REQ-SRV-03]`.
      - Copy the structure of `protocol-v2-handshake.integ.test.ts` lines 1-68 verbatim, substituting:
        - 2 → 3 (the old version being rejected) — wait, re-read: PROTOCOL_VERSION-2 test rejected version 1. The new v4 test should reject version 3 specifically (the immediate predecessor) and accept version 4. Read the v2 analog to confirm the exact pattern (the v2 analog likely rejects v1 and accepts v2).
        - Asserts `expect(PROTOCOL_VERSION).toBe(4);` at the top.
        - Reject path: `protocol_version: 3` → expect close 4400 with `PROTOCOL_VERSION_MISMATCH`.
        - Accept path: `protocol_version: 4` → expect successful join.
      - Use `client.joinOrCreate('rebno', { protocol_version: <N>, session_token: 'dev-bypass' })` exactly as the analog does at line ~38.
    </action>
    <verify>
      <automated>cd apps/server && pnpm exec vitest run test/position-update.integ.test.ts test/protocol-v4-handshake.integ.test.ts</automated>
    </verify>
    <done>Two-client integ proves the position_update + set_sprite_override roundtrip and the stale-seq guard end-to-end; v3 client rejection is enforced; v4 client still accepted.</done>
    <acceptance_criteria>
      - Source assertion: file `apps/server/test/position-update.integ.test.ts` exists and contains at least one `describe(` block and 5 `it(` blocks
      - Source assertion: file `apps/server/test/protocol-v4-handshake.integ.test.ts` exists; `grep -n "PROTOCOL_VERSION_MISMATCH" apps/server/test/protocol-v4-handshake.integ.test.ts` matches at least once
      - Source assertion: `grep -nE "protocol_version: ?3" apps/server/test/protocol-v4-handshake.integ.test.ts` matches the reject case
      - Source assertion: `grep -nE "protocol_version: ?4" apps/server/test/protocol-v4-handshake.integ.test.ts` matches the accept case
      - Behavior assertion: `pnpm -C apps/server exec vitest run test/position-update.integ.test.ts test/protocol-v4-handshake.integ.test.ts` exits 0 with all listed cases passing
      - Regression assertion: `pnpm -C apps/server exec vitest run` (full server suite) exits 0 — no other tests broken by the rate-limit / state-write changes from Task 1 or the tickLoop change from Task 2
      - Tag assertion: `grep -c "int->REQ-CLI-08" apps/server/test/position-update.integ.test.ts` ≥ 1
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server `onMessage('position_update')` | zod-validate + rate-limit + server-tagged identity gate the only ingress for client-claimed position |
  | client → server `onMessage('set_sprite_override')` | Same gate; closed enum for sprite_id |
  | server → client `PlayerState` state-diff broadcast | Standard Colyseus channel; no new untrusted data crosses |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-01b | Tampering | client-fabricated `{x, y, vx, vy}` carrying impossible values | **accept (semantic) + mitigate (envelope)** | Per D-11, server semantic legality (speed cap, walkable-grid) is OUT OF SCOPE — deferred to candidate Phase 06.8. **In 06.7 the operator pool is trusted and 50 CCU is the cap.** Envelope-only defense: int16 bounds reject -32769+ values at zod parse; rate-limit token bucket (35 tokens/sec / burst 60) caps tick-rate abuse; stale-seq guard rejects replays. Threat is documented and consciously not mitigated semantically. |
  | T-06.7-02 | Tampering | `set_sprite_override` injection (e.g. unknown sprite_id, malformed type) | mitigate | `cSetSpriteOverrideSchema` is a tight z.enum closed set + `.strict()`. Rejection at handler `safeParse`. No state mutation possible from a malformed payload. |
  | T-06.7-03 | DoS | `position_update` flood from a runaway / hostile client (1000+ Hz) | mitigate | Token bucket per `(account_id, 'position_update')` with refill 35 tokens/sec, burst 60. RESEARCH §"Pitfall 3". `position_update` is added to the existing rate-limit msg_type union; cheap-path-before-zod pattern from onMessageHandlers:69-96 reused. |
  | T-06.7-04 | Spoofing | client sends position_update claiming `{ account_id: 'victim' }` field | mitigate | `.strict()` on cPositionUpdateSchema rejects any extra field. Server-tagged identity from `ctx.getAuth(client.sessionId).account_id` per existing pattern; wire-side identity is never consulted. PlayerState key is `client.sessionId`, never wire-supplied. |
  | T-06.7-06 | Tampering | TCP-reorder replay of an older position_update bumps stale state | mitigate | `if (parsed.data.seq <= player.last_input_seq) return;` stale-seq guard. RESEARCH §"Pattern 4". Cheap; defends against the rare TCP-reorder-after-reconnect window. |
  | T-06.7-07 | Integrity (regression) | A future refactor accidentally re-introduces server-side position derivation, double-writing player.x | mitigate | Player pass-through in step.ts is documented + tagged `// [impl->REQ-SRV-14]`. The platform-advance regression test in Task 2 + the pass-through regression test guard against revert. Plan 03's reconciler-no-op test guards the client side. |

  **Doctrine acknowledgement (D-11):** Server-side anti-cheat envelope is OUT OF SCOPE per CONTEXT D-11. This plan ships ZERO semantic position-legality checks. The trade is acceptable on a pre-launch private staging with ≤50 trusted operators; once cheating becomes observable on real-user traffic OR before public launch, candidate Phase 06.8 ships speed-cap + walkable-grid sanity + fall-reset. This plan's threat register documents that acceptance explicitly so a future verifier reading the post-mortem understands the deferral chain.

  **CLAUDE.md Hard Rule 1 cross-reference:** Plan 04 lands the doctrine edit (D-13) that codifies the trust split. This plan implements the substrate.
  </threat_model>

  <verification>
  - `pnpm -C apps/server test` green for the full server unit suite (position-update.test.ts + sprite-override.test.ts + existing tests)
  - `pnpm -C apps/server test:integration` green for the full server integ suite (position-update.integ.test.ts + protocol-v4-handshake.integ.test.ts + existing integs)
  - `pnpm -C packages/game-logic test` green (step.test.ts platform + pass-through cases + existing cases)
  - `pnpm -C apps/server exec tsc --noEmit` clean
  - `pnpm trace:check` does not regress on REQ-SRV-03 / REQ-SRV-14 / REQ-CLI-08
  - Manual sanity: `grep -nE "onMessage\('position_update'" apps/server/src/onMessageHandlers.ts | wc -l` returns 1 (no accidental duplicate handlers)
  </verification>

  <success_criteria>
  - Server accepts authed position_update + set_sprite_override and writes PlayerState through Colyseus state-diff broadcast — verified by two-client integ
  - Server tickLoop step() is platforms-only — players round-trip; existing platform tests still green
  - v3 client rejected at handshake; v4 client accepted
  - Stale-seq guard verified end-to-end
  - No existing server / game-logic tests broken
  - Plan 03 (client) is unblocked: client can `room.send('position_update', payload)` and observe its own state on the server, plus see remote players' position via the existing PlayerState state-diff channel
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-02-SUMMARY.md`. Note: the heldInputs vestigial cleanup is intentionally deferred to 06.9 — document this with the TODO line-number reference so the future cleanup phase has a concrete starting point. Also document the rate-limit budget choice (35/sec burst 60) so operator dashboards know the dropped-rate baseline.
  </output>

  ---
  #### 06.7-03-PLAN.md
  ---
  phase: 06.7
  plan: 03
  type: execute
  wave: 2
  depends_on:
    - 06.7-01
  files_modified:
    - apps/client/src/prediction/position-dispatcher.ts
    - apps/client/src/prediction/reconciler.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/net/colyseus-client.ts
    - apps/client/src/__test__/position-dispatcher.test.ts
    - apps/client/src/__test__/reconciler.test.ts
  autonomous: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
  must_haves:
    truths:
      - "Client emits a c2s.position_update per sim tick (~30 Hz) populated from PredictionEngine.getLocalState + SpriteStateMachine output"
      - "Client emits a c2s.position_update per sim tick EVEN WHEN THE PLAYER IS IDLE (D-06 steady cadence — getLocalState() returning {vx:0, vy:0} still produces a valid payload that passes cPositionUpdateSchema.safeParse)"
      - "ReconcileEngine.onServerSnapshot is a NO-OP for the self-player (returns early when called from the self-snapshot path) — sprite.setPosition and sprite.tweenTo are never called for self from this code path"
      - "ReconcileEngine.onServerSnapshot for remote players preserves existing lerp/threshold behavior (remote-path symmetry retained per the W7 lock)"
      - "GameScene invokes PositionDispatcher.sendTick on the local sim-tick boundary (the 30 Hz scheduler already in place at apps/client/src/scenes/GameScene.ts:1166-1206 — the `while (this.simTickAccumulator >= SIM_TICK_MS)` loop inside `override update(_time, dt)`)"
      - "GameScene's call site that previously invoked reconcile.onServerSnapshot for self passes the new isSelfPlayer=true flag so the call body returns early (D-04 positive guard)"
      - "globalThis.__rebno.lastReconcile* writes for the self-snapshot path are gone (no diagnostic-ring-buffer entries written when isSelfPlayer=true)"
      - "D-57b and D-58b anti-revert tests are retired (the reconciler-snap behavior they guarded is now intentionally absent); replaced by the positive guard test"
      - "PROTOCOL_VERSION sent on handshake = 4"
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        provides: "PositionDispatcher class — sendTick(monotonic_at_ms) builds + sends c2s.position_update"
        contains: "PositionDispatcher"
      - path: "apps/client/src/prediction/reconciler.ts"
        provides: "onServerSnapshot with isSelfPlayer self-noop branch + header doc + globalThis ringbuffer removal for self"
        contains: "isSelfPlayer"
      - path: "apps/client/src/scenes/GameScene.ts"
        provides: "PositionDispatcher instantiation + per-tick send; reconciler self-snapshot call passes isSelfPlayer=true"
        contains: "PositionDispatcher"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        provides: "Unit tests — sendTick payload shape, seq monotonicity, packAnimState wiring, D-06 idle cadence guard"
        contains: "sendTick"
      - path: "apps/client/src/__test__/reconciler.test.ts"
        provides: "D-04 positive-guard it case — self-snapshot is no-op + retiring D-57b/D-58b cases + remote-symmetry preserved tests use isSelfPlayer=false"
        contains: "D-04"
    key_links:
      - from: "apps/client/src/scenes/GameScene.ts (per-tick update loop)"
        to: "PositionDispatcher.sendTick(monotonic_at_ms)"
        via: "invoked from the same scheduler that calls predictTick — GameScene.ts:1166-1206 (inside `override update(_time, dt)`), immediately after `this.prediction.predictTick(...)` at line 1168"
        pattern: "positionDispatcher\\.sendTick|posDispatcher\\.sendTick"
      - from: "apps/client/src/prediction/reconciler.ts onServerSnapshot"
        to: "early-return when isSelfPlayer"
        via: "isSelfPlayer flag parameter (or local-id comparison)"
        pattern: "if \\(isSelfPlayer\\)"
      - from: "apps/client/src/scenes/GameScene.ts:761 (existing reconcile call site)"
        to: "onServerSnapshot(snap, isSelfPlayer=true)"
        via: "pass the new flag (or stop calling for self entirely)"
        pattern: "reconcile\\?\\.onServerSnapshot"
  ---

  <objective>
  Wire the client side of the client-trust flip: introduce `PositionDispatcher` that ships the new c2s.position_update every sim tick (including idle ticks per D-06); make `ReconcileEngine.onServerSnapshot` a no-op for the self-player (positive D-04 guard) while PRESERVING the remote-player lerp/threshold path for signature symmetry (W7 lock); remove the self-path diagnostic-ring-buffer writes; update GameScene to instantiate the dispatcher, schedule its `sendTick` inside the existing 30 Hz `override update(_time, dt)` accumulator loop at lines 1166-1206, and pass `isSelfPlayer=true` when invoking the reconciler for self; bump handshake `protocol_version` to 4.

  Purpose: D-04 (D-57b/D-58b retired + positive guard) + D-05/D-06/D-07/D-08/D-12 land client-side here. After this plan, self-player position is OWNED by the client; the round-trip diagonal-stop drift bug from operator UAT 2026-05-16 cannot recur (no snap path remains).
  Output: 1 NEW dispatcher file + reconciler self-noop edits (remote path preserved) + GameScene wiring + colyseus-client handshake bump + 2 test files (1 new dispatcher unit + reconciler test additions/retirements). Plan touches 6 files with 5 edit-regions in GameScene.ts (import + field declaration + instantiation + per-tick invocation + reconnect setRoom + isSelfPlayer pass-through); kept co-located in a single task per W6 acceptance.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-PLAN.md
  @apps/client/src/prediction/predictor.ts
  @apps/client/src/prediction/reconciler.ts
  @apps/client/src/prediction/input-dispatcher.ts
  @apps/client/src/net/colyseus-client.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/__test__/reconciler.test.ts
  @apps/client/src/__test__/input-dispatcher.test.ts

  <interfaces>
  <!-- From Plan 01 (@rebno/protocol) — already importable: -->
    packAnimState(facing, isRunning): number
    FACING_OCTANT: Record<Direction, number>
    type CPositionUpdate, type Direction (or imported from intents.ts via re-export)
    cPositionUpdateSchema (for test fixtures, optional)
    PROTOCOL_VERSION (literal 4)

  <!-- From existing client code (KEEP — extend, do not redefine): -->
    PredictionEngine in apps/client/src/prediction/predictor.ts
      - getLocalState(): { x: number, y: number, vx: number, vy: number }
        (or equivalent — if the exact name differs, executor confirms via Read first; the spec is "current local sim state for self")
      - predictTick(layout, axes) — runs locally each frame (UNCHANGED in 06.7)
    SpriteStateMachine in apps/client/src/render/SpriteStateMachine.ts
      - exposes current pose / running state (consumed by getIsRunning callback)
    InputDispatcher in apps/client/src/prediction/input-dispatcher.ts
      - sendInput(axes) — for chat-focus pauseMovement contract (KEPT vestigially; not the focus of this plan)
      - HEARTBEAT_INTERVAL_MS = 15_000 (KEPT — used in chat-focus / menu state)
    ReconcileEngine in apps/client/src/prediction/reconciler.ts
      - onServerSnapshot(snap, isSelfPlayer) — body becomes self-noop branch; remote-path lerp/threshold preserved per W7 lock
    GameScene in apps/client/src/scenes/GameScene.ts
      - line 79: import { ReconcileEngine }
      - line 131: this.reconcile field declaration
      - line 709: this.reconcile = new ReconcileEngine(...)
      - line 761: this.reconcile?.onServerSnapshot({ ... }) — the self-call site
      - line 1152: `override update(_time: number, dt: number): void` — the per-frame entry
      - lines 1166-1206: `while (this.simTickAccumulator >= SIM_TICK_MS)` — the 30 Hz sim-tick loop; predictTick() runs at 1168; PositionDispatcher.sendTick() is wired immediately after the predictTick call inside this same loop body (one tick = one position_update, including idle ticks per D-06)

  <!-- Wire shape (from Plan 01): -->
    c2s.position_update payload = {
      type: 'position_update',
      x, y, vx, vy: int16-rounded
      facing: 'D'|'DR'|...
      anim_state: u8 packed via packAnimState
      seq: u32 monotonic per dispatcher
      monotonic_at_ms: u32
    }
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: PositionDispatcher class + unit tests (incl. D-06 idle-cadence guard)</name>
    <read_first>
      - apps/client/src/prediction/input-dispatcher.ts (FULL — `sendInput` @ lines 256-286 is the analog; the class shape with `_room`/`setRoom` @ lines 76-118 is the lifecycle precedent for Colyseus reconnect; HEARTBEAT_INTERVAL_MS @ line 26)
      - apps/client/src/prediction/predictor.ts (the PredictionEngine API — confirm the exact name of the "get current local sim state" method; predictTick + getLocalState or equivalent)
      - apps/client/src/render/SpriteStateMachine.ts (the SpriteStateMachine API — confirm how to query current facing + isRunning; the position_update needs both)
      - apps/client/src/__test__/input-dispatcher.test.ts (FULL — `makeMockRoom`, `makeMockPrediction`, `vi.useFakeTimers` pattern; this is the test scaffolding to mirror)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/prediction/position-dispatcher.ts (NEW)"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Position dispatcher (client, every-tick send)" (full code spec)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-VALIDATION.md (row "D-06 idle cadence guard — PositionDispatcher.sendTick emits valid payload when getLocalState returns {vx:0, vy:0}")
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-06 (steady 30 Hz cadence; idle player still emits position_update)
      - packages/protocol/src/anim-state.ts (Plan 01 — packAnimState + FACING_OCTANT exports)
    </read_first>
    <behavior>
      - new PositionDispatcher(room, prediction, getFacing, getIsRunning); calling sendTick(1000) invokes room.send('position_update', payload) exactly once
      - The payload's `type` field equals 'position_update'
      - The payload's x, y, vx, vy are Math.round() of the prediction.getLocalState() values (i.e. quantized to int16)
      - The payload's `facing` equals what `getFacing()` returns
      - The payload's `anim_state` equals `packAnimState(getFacing(), getIsRunning())`
      - The payload's `seq` is monotonically increasing across multiple sendTick calls (1, 2, 3, ...)
      - The payload's `monotonic_at_ms` equals the value passed to sendTick
      - setRoom(newRoom) routes subsequent sendTick calls to newRoom (Colyseus reconnect lifecycle); the old room.send is NOT called after setRoom
      - cPositionUpdateSchema.safeParse(payload).success === true (envelope correctness)
      - **D-06 idle-cadence guard (REQ-CLI-04 — addresses checker W3):** when `prediction.getLocalState()` returns `{ x: 500, y: 400, vx: 0, vy: 0 }` (the player has been idle for N frames), sendTick(monotonic_at_ms) STILL invokes `room.send('position_update', payload)` exactly once and the emitted payload passes `cPositionUpdateSchema.safeParse(...).success === true`. The dispatcher does NOT skip / coalesce / suppress idle ticks. Asserts (a) `room.send` call count == 1 per sendTick invocation regardless of vx/vy = 0, (b) `payload.vx === 0`, `payload.vy === 0`, and (c) the safeParse success — proving the server's zod gate will accept the idle frame.
    </behavior>
    <action>
      Create `apps/client/src/prediction/position-dispatcher.ts`. Tag the module header `// [impl->REQ-CLI-04] [impl->REQ-CLI-08]`. Implement per the spec in RESEARCH §"Position dispatcher (client, every-tick send)" and PATTERNS.md §"Target shape (NEW)":
      - Class `PositionDispatcher` with constructor taking `(room: Pick<Room, 'send'>, prediction: PredictionEngine, getFacing: () => Direction, getIsRunning: () => boolean)`.
      - Mutable `_room` + `setRoom(room)` setter mirroring InputDispatcher lines 76-118 for Colyseus reconnect lifecycle.
      - `private nextSeq = 0;`
      - Public `sendTick(monotonic_at_ms: number): void` that builds the payload from `prediction.getLocalState()` (confirm exact method name during the Read step; if the API exposes the local sim state under a different name, adapt the action — the contract is "current local sim x/y/vx/vy for self"), rounds each numeric field with Math.round, queries facing + running via the injected callbacks, packs anim_state, increments seq, and calls `_room.send('position_update', { type, x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms })`. **Per D-06 the method does NOT gate on vx/vy = 0** — idle player still emits a payload every tick (steady 30 Hz cadence).
      - Imports: `import { packAnimState, type Direction } from '@rebno/protocol';` + type Room from '@colyseus/sdk' + the predictor type.

      Create `apps/client/src/__test__/position-dispatcher.test.ts`. Tag header `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Mirror `apps/client/src/__test__/input-dispatcher.test.ts` lines 1-80:
      - `makeMockRoom()` returns `{ send: vi.fn() }`.
      - `makeMockPrediction()` returns an object exposing whatever method PositionDispatcher consumes (`getLocalState()` returns `{ x: 100.7, y: 200.2, vx: 1.1, vy: -2.4 }` or similar — non-integer to verify Math.round). Also export a helper `makeIdlePrediction()` returning `{ getLocalState: () => ({ x: 500, y: 400, vx: 0, vy: 0 }) }` for the D-06 case below.
      - Use `vi.useFakeTimers()` setup if any timer interaction is needed (probably none — sendTick is synchronous).
      - Test cases mirror the 10 behaviors above (the original 9 + the new D-06 idle-cadence guard). Assertion shape: `room.send.mock.calls[0]![1]` is the payload object; compare field-by-field with `toEqual` / `toMatchObject`.
      - Round-trip case: `expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true)` — proves the dispatcher emits a payload that the server's zod gate will accept.
      - **D-06 idle-cadence test (NEW — addresses W3):**
        ```
        it('D-06 06.7 — sendTick emits a valid payload when player is idle (vx=0, vy=0)', () => {
          const room = makeMockRoom();
          const prediction = makeIdlePrediction(); // getLocalState() → { x: 500, y: 400, vx: 0, vy: 0 }
          const dispatcher = new PositionDispatcher(room, prediction, () => 'D', () => false);
          dispatcher.sendTick(1000);
          expect(room.send).toHaveBeenCalledTimes(1);
          const [, payload] = room.send.mock.calls[0]!;
          expect(payload.vx).toBe(0);
          expect(payload.vy).toBe(0);
          expect(payload.x).toBe(500);
          expect(payload.y).toBe(400);
          expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true);
        });
        ```

      Note: this task does NOT wire the dispatcher into GameScene — that's Task 3. The dispatcher must be unit-testable in isolation.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>PositionDispatcher class exists, is exportable, builds correct payloads that pass cPositionUpdateSchema.safeParse; D-06 idle-cadence guard test green; unit tests green.</done>
    <acceptance_criteria>
      - Source assertion: file `apps/client/src/prediction/position-dispatcher.ts` exists; `grep -n "export class PositionDispatcher" apps/client/src/prediction/position-dispatcher.ts` matches exactly one line
      - Source assertion: `grep -n "packAnimState" apps/client/src/prediction/position-dispatcher.ts` matches at least one line (anim_state wiring)
      - Source assertion: `grep -n "Math.round" apps/client/src/prediction/position-dispatcher.ts` matches at least 4 lines (x, y, vx, vy rounding per D-07)
      - Source assertion: `grep -n "setRoom" apps/client/src/prediction/position-dispatcher.ts` matches at least one line (reconnect lifecycle)
      - Source assertion: file `apps/client/src/__test__/position-dispatcher.test.ts` exists with at least 9 `it(` blocks (8 original + the new D-06 idle-cadence case)
      - Source assertion: `grep -nE "D-06 06\.7|D-06 idle|idle cadence" apps/client/src/__test__/position-dispatcher.test.ts` matches at least one line (the D-06 idle test is present)
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts` exits 0; both the cPositionUpdateSchema.safeParse-success case AND the D-06 idle-cadence case pass
      - Tag assertion: `grep -c "impl->REQ-CLI-04" apps/client/src/prediction/position-dispatcher.ts` ≥ 1
      - Tag assertion: `grep -c "unit->REQ-CLI-04" apps/client/src/__test__/position-dispatcher.test.ts` ≥ 1
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: ReconcileEngine self-noop + reconciler test (D-04 positive guard, retire D-57b/D-58b, PRESERVE remote symmetry)</name>
    <read_first>
      - apps/client/src/prediction/reconciler.ts (FULL — onServerSnapshot @ 52-122 is the surgical target; the globalThis.__rebno.lastReconcile* writes @ 73-92 + 100-119 are what's being removed for the self-path)
      - apps/client/src/__test__/reconciler.test.ts (FULL — the existing 5+ it cases including the D-57b/D-58b anti-revert ones; identify which ones to retire vs. keep with isSelfPlayer=false)
      - apps/client/src/scenes/GameScene.ts (lines 700-790 — the existing call site at line 761 that invokes reconcile.onServerSnapshot for self; that signature changes to accept isSelfPlayer)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/prediction/reconciler.ts"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 3" §"Pitfall 1" §"Pitfall 6"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-04 (the retire-then-replace acceptance criterion)
    </read_first>
    <behavior>
      Reconciler unit (apps/client/src/__test__/reconciler.test.ts):
      - D-04 positive guard: `eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true)` produces ZERO calls to `m.setPosition` and ZERO calls to `m.tweenTo` (mockAdapter spies)
      - D-04 corollary: even with extreme divergence (>22 px threshold), self-path is no-op
      - D-04 corollary: the globalThis ringbuffer for self is NOT written (`globalThis.__rebno?.lastReconcileSelfX` etc. remain undefined or at their pre-call value)
      - Existing D-57b and D-58b anti-revert tests are REMOVED from the file (their preconditions no longer make sense — they asserted snap-on-divergence behavior that is intentionally absent)
      - **Remote-symmetry preserved (W7 lock):** any existing lerp/threshold cases for the REMOTE-player path are RETAINED and updated to pass `isSelfPlayer=false` EXPLICITLY. The remote-player branch of `onServerSnapshot` is NOT collapsed to a no-op; the existing lerp/threshold sprite-write behavior is preserved end-to-end. Specifically: a remote case calling `onServerSnapshot(snap, /* isSelfPlayer */ false)` with a >22 px divergence MUST still produce a `m.setPosition` (or `m.tweenTo`) call, matching the pre-06.7 remote-reconciliation behavior. A remote case below threshold continues to behave as it did pre-06.7.

      Reconciler source (apps/client/src/prediction/reconciler.ts):
      - onServerSnapshot signature accepts `isSelfPlayer: boolean` as a second parameter (or new option object — pick the cleanest fit for the existing callers, which is GameScene only)
      - When `isSelfPlayer === true`, the function returns early immediately after the param decode; NO sprite writes, NO tween, NO globalThis writes
      - When `isSelfPlayer === false` (remote-player path — **PRESERVED per W7 lock**) the existing lerp/threshold + sprite-write behavior stays intact. Do NOT collapse to a no-op. Do NOT delete the divergence-threshold sprite write. Remote-player rendering is OUT OF SCOPE for 06.7 changes — only the self-player branch flips.
    </behavior>
    <action>
      In `apps/client/src/prediction/reconciler.ts`:
      - Update the file header (lines 1-16) per PATTERNS.md §"Header-comment update pattern":
        ```
        // [impl->REQ-CLI-04]
        // 06.7 D-04 — ReconcileEngine.onServerSnapshot is a NO-OP for the self-player.
        // Self-player position is owned by the client (predictor.ts + PositionDispatcher);
        // snapshot-driven snap/lerp would re-introduce the diagonal-stop ~10 px drift
        // (operator UAT 2026-05-16). The REMOTE-player branch is PRESERVED with its
        // existing lerp/threshold behavior — only the self-player branch flips
        // (W7 lock: remote symmetry intentionally retained; remote rendering
        // continues to flow through onServerSnapshot's remote-path code).
        ```
      - Change the signature of `onServerSnapshot(snap)` to `onServerSnapshot(snap, isSelfPlayer: boolean)`.
      - At the very top of the method body: `if (isSelfPlayer) { return; }` — comment `// [impl->REQ-CLI-04] D-04 positive guard — see file header.`
      - REMOVE the globalThis.__rebno.lastReconcile* writes that occur on the self-path (per PATTERNS.md "Drop the globalThis ring-buffer write for the self-player branch (lines 73-92 + 100-119)"). The diagnostic ring-buffer is no longer useful for self; it remains in the file's history for posterity but the runtime no longer writes when isSelfPlayer=true.
      - **PRESERVE the remote-path lerp/threshold code below the self-guard return.** Do NOT delete `DIVERGENCE_THRESHOLD_PX` constant. Do NOT delete the sprite.setPosition/tweenTo call paths in the remote branch. Remove the `@deprecated` JSDoc note that the previous plan-revision draft suggested — per W7 the constant IS still live for remote reconciliation.

      In `apps/client/src/scenes/GameScene.ts`:
      - Update the existing call site at line ~761 `this.reconcile?.onServerSnapshot({ ... })` to pass `isSelfPlayer: true` as the new second argument. (The call site IS the self-snapshot path — verify by re-reading the surrounding context at lines 740-780; the snapshot is built from the local player's PlayerState row, so isSelfPlayer is unconditionally true here.) Prefer passing `isSelfPlayer=true` to preserve the call structure and avoid touching the surrounding fired-callback logic at line 776.
      - Add `scene.tweens.killTweensOf(localSprite)` defensively in the predictor-write path per RESEARCH §"Pitfall 6" (Phaser tween fighting predictor) — only if a tween reference still exists in the local-sprite write path. If no in-flight tween path remains (i.e. no GameScene code calls `tweenTo` against localSprite outside of the now-noop reconciler), skip this defensive call. Document the decision in a code comment.

      In `apps/client/src/__test__/reconciler.test.ts`:
      - Add the D-04 positive-guard `it` block per PATTERNS.md §"reconciler.test.ts (ADD case)":
        ```
        it('D-04 06.7 — onServerSnapshot is a NO-OP for self-player (no setPosition, no tweenTo)', () => {
          const m = mockAdapter(100, 100);
          const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
          eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true);
          expect(m.setPosition).not.toHaveBeenCalled();
          expect(m.tweenTo).not.toHaveBeenCalled();
        });
        ```
      - Add a second case asserting that the globalThis ring-buffer is NOT written when isSelfPlayer=true (sanity).
      - REMOVE (delete) the D-57b and D-58b anti-revert test cases entirely — they guard the now-absent SELF snap behavior. Per CONTEXT D-04 these are EXPLICITLY RETIRED as an acceptance criterion of this phase. Add a deleted-block tombstone comment near the top of the file: `// D-57b + D-58b anti-revert tests retired 06.7 per CONTEXT D-04 — replaced by the D-04 positive-guard above. Remote-path lerp/threshold tests RETAINED per W7 lock; updated to pass isSelfPlayer=false explicitly.`
      - **REMOTE SYMMETRY (W7 lock):** For ALL remaining lerp/threshold cases — including the below-threshold no-op case AND the above-threshold sprite-write case — update each call to pass `isSelfPlayer: false` explicitly. These tests MUST continue to pass after the signature change. If any existing remote-path test currently calls `eng.onServerSnapshot(snap)` without a flag, change to `eng.onServerSnapshot(snap, /* isSelfPlayer */ false)`. Do NOT retire the remote-path cases — they guard the remote-player rendering that the 06.7 operator UAT does NOT exercise (UAT focuses on self-player movement). Removing them risks an undetected remote-rendering regression.

      Update the mock adapter trick `apps/client/src/__test__/game-scene.test.ts:91-92` (the `ReconcileEngine: class { onServerSnapshot = vi.fn(); }` mock) only if signature change breaks the mock — the vi.fn() accepts any args, so likely no change needed. Re-run the suite to confirm.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/reconciler.test.ts</automated>
    </verify>
    <done>Reconciler self-noop is enforced + tested; D-57b/D-58b retired; D-04 positive guard passes; remote-path lerp/threshold tests preserved with explicit isSelfPlayer=false; GameScene caller updated; no Phaser-tween-fighting-predictor regression possible.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "if \(isSelfPlayer\)" apps/client/src/prediction/reconciler.ts` matches at least one line inside onServerSnapshot
      - Source assertion: `grep -nE "isSelfPlayer: boolean" apps/client/src/prediction/reconciler.ts` matches the signature
      - Source assertion: `grep -n "06.7 D-04" apps/client/src/prediction/reconciler.ts` matches the header comment
      - Source assertion (W7 lock): `grep -n "DIVERGENCE_THRESHOLD_PX" apps/client/src/prediction/reconciler.ts` matches at least one line (constant retained, NOT deprecated)
      - Source assertion (W7 lock): the remote-path sprite-write code is intact — `grep -nE "setPosition|tweenTo" apps/client/src/prediction/reconciler.ts` matches at least one line BELOW the self-guard return
      - Source assertion: D-57b and D-58b cases are GONE from the test file — `grep -cE "describe.*D-57b|describe.*D-58b|it.*D-57b|it.*D-58b" apps/client/src/__test__/reconciler.test.ts` returns 0 OR matches only the retirement tombstone comment (verify by reading the diff)
      - Source assertion: D-04 positive guard exists — `grep -n "D-04 06.7" apps/client/src/__test__/reconciler.test.ts` matches at least one line
      - Source assertion (W7 lock): at least one remote-path test uses the explicit flag — `grep -nE "isSelfPlayer.*false|/\*\s*isSelfPlayer\s*\*/\s*false" apps/client/src/__test__/reconciler.test.ts` matches at least one line
      - Source assertion: the GameScene call site passes the new flag — `grep -nE "onServerSnapshot\(\{[^}]*\},\s*(true|/\*\s*isSelfPlayer\s*\*/\s*true)" apps/client/src/scenes/GameScene.ts` matches
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/reconciler.test.ts` exits 0 with the D-04 case passing AND the preserved remote-path lerp/threshold cases passing
      - Regression assertion: `pnpm -C apps/client exec vitest run src/__test__/game-scene.test.ts` exits 0 (the mock-adapter trick still works with the new signature)
      - Tag assertion: `grep -c "impl->REQ-CLI-04" apps/client/src/prediction/reconciler.ts` ≥ 1
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: GameScene + colyseus-client wiring — instantiate dispatcher, schedule sendTick at GameScene.ts:1166-1206, handshake protocol_version=4</name>
    <read_first>
      - apps/client/src/scenes/GameScene.ts (FULL — line 79 ReconcileEngine import; line 131 reconcile field; line 709 reconcile instantiation; line 761 self-snapshot call; **line 1152 `override update(_time: number, dt: number)` — the per-frame entry**; **lines 1166-1206 `while (this.simTickAccumulator >= SIM_TICK_MS)` — the existing 30 Hz sim-tick scheduler**; **line 1168 `const localSim = this.prediction.predictTick(this.currentLayoutForSim());` — the per-tick predictTick invocation that PositionDispatcher.sendTick is wired next to**; the InputDispatcher per-tick path uses `axisX()`/`axisY()` at 1174-1175 — the dispatcher itself does not have a per-sim-tick send in this file, so PositionDispatcher.sendTick is the new 30 Hz call site)
      - apps/client/src/net/colyseus-client.ts (FULL — `bindHandlers` @ lines 237-258, the player onAdd / onChange wiring; the handshake's `joinOrCreate` call — where the `protocol_version` argument is set; current literal 3 needs to become 4)
      - apps/client/src/prediction/input-dispatcher.ts (the InputDispatcher per-tick or per-event scheduling pattern; PositionDispatcher's scheduling lives in the same scene loop)
      - apps/client/src/__test__/input-dispatcher.test.ts (test patterns for any GameScene-level dispatcher schedule, if applicable)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/net/colyseus-client.ts" §"Per-player onChange wiring"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pitfall 4" (PROTOCOL_VERSION bump deploy ritual)
      - packages/protocol/src/version.ts (Plan 01 — PROTOCOL_VERSION = 4)
    </read_first>
    <behavior>
      - GameScene instantiates a PositionDispatcher alongside InputDispatcher and stores it on the scene (private field declared alongside the existing `reconcile` field at line ~131)
      - **Scheduler hook (pre-resolved per W4):** GameScene invokes `this.positionDispatcher?.sendTick(_time)` INSIDE the existing 30 Hz `while (this.simTickAccumulator >= SIM_TICK_MS)` loop in `override update(_time, dt)` at apps/client/src/scenes/GameScene.ts:1166-1206. The call is placed IMMEDIATELY AFTER the existing `const localSim = this.prediction.predictTick(...)` invocation at line 1168 (executor reads the surrounding context lines 1166-1206 to splice the new call at the canonical spot — same loop iteration, after predictTick completes, before or after the remote-player onSimulationTickRemote loop; either ordering is acceptable since sendTick is independent of remote state). DO NOT invent a new timer. DO NOT use `performance.now()` — use the `_time` parameter already passed to `override update(_time, dt)` (it is Phaser's monotonic timestamp and is what the rest of the loop body uses for telemetry at line 1218).
      - On Colyseus reconnect, the scene calls `this.positionDispatcher?.setRoom(newRoom)` (mirroring InputDispatcher.setRoom)
      - colyseus-client.ts handshake sends `protocol_version: 4` (NOT 3) — verify literal change OR confirm the value is sourced from `PROTOCOL_VERSION` from `@rebno/protocol` and that the source value is 4 (Plan 01)
      - The reconciler self-snapshot call site in GameScene passes `isSelfPlayer: true` (Task 2 already changed this — confirm it survives the wiring edits here)
      - bindHandlers' `players.onAdd` callback still reads `player.anim_state` / `player.sprite_override` from PlayerState for remote rendering per RESEARCH §"Pitfall 8" (existing per-instance `$(player).onChange` already fires for the new fields — verify nothing in the renderer chokes on the new values)
    </behavior>
    <action>
      In `apps/client/src/scenes/GameScene.ts` — the edits are co-located in 5 regions (W6 accepted-as-is single task):

      1. **Import (line ~79 region, alongside ReconcileEngine):**
         `import { PositionDispatcher } from '../prediction/position-dispatcher.js';`

      2. **Field declaration (line ~131, alongside the existing `reconcile` field):**
         `private positionDispatcher: PositionDispatcher | undefined;`

      3. **Instantiation (line ~700-720, alongside InputDispatcher / ReconcileEngine setup):**
         `this.positionDispatcher = new PositionDispatcher(room, this.prediction, () => this.getCurrentFacing(), () => this.spriteStateMachine.isRunning());`
         — the exact callback wiring depends on the SpriteStateMachine / facing-source API in the scene; the executor reads the surrounding scene state to plug in correctly. The contract: `getFacing` returns the current Direction; `getIsRunning` returns whether the sprite is in a RUN_* pose.

      4. **Per-tick invocation — PRE-RESOLVED HOOK (W4):**
         Inside `override update(_time: number, dt: number)` at line 1152, INSIDE the existing `while (this.simTickAccumulator >= SIM_TICK_MS)` loop body (lines 1166-1206), IMMEDIATELY AFTER the existing `const localSim = this.prediction.predictTick(this.currentLayoutForSim());` call at line 1168, splice in:
         ```
         // [impl->REQ-CLI-04] [impl->REQ-CLI-08] D-05/D-06 — emit position_update at
         // the 30 Hz sim-tick boundary. Idle ticks STILL fire (D-06 steady cadence).
         this.positionDispatcher?.sendTick(_time);
         ```
         Use `_time` (Phaser's monotonic timestamp, already in scope as the first parameter of `override update`), NOT `performance.now()`. The placement is unambiguous: same iteration of the while-loop that runs predictTick, before the loop exits. Co-location with predictTick guarantees one position_update per simulation tick — including idle ticks (D-06).

      5. **Reconnect handler (search for `this.input.setRoom(newRoom)` or equivalent — likely in the room-rebind path):**
         Add `this.positionDispatcher?.setRoom(newRoom);` alongside the existing input.setRoom call.

      PRESERVE the Task 2 edit where `this.reconcile?.onServerSnapshot({ ... }, /* isSelfPlayer */ true)` is passed; confirm the call still compiles.

      In `apps/client/src/net/colyseus-client.ts`:
      - Locate the `joinOrCreate('rebno', { protocol_version: ..., session_token: ... })` call (handshake). Per RESEARCH §"Pitfall 4" the version is bumped to 4 alongside the protocol package's PROTOCOL_VERSION constant. PREFER to source the value as `protocol_version: PROTOCOL_VERSION` (imported from `@rebno/protocol`) over a hardcoded literal — this prevents future drift between the protocol package and the client. If the call currently uses a literal `3`, replace with `PROTOCOL_VERSION` and add the import. If the call already uses `PROTOCOL_VERSION` (sourced from the bumped constant), no source change is needed — the bump in Plan 01 took effect.
      - In `bindHandlers` (lines 237-258), confirm the per-player `$(player).onChange(...)` callback reads through any consumers of the new `player.anim_state` / `player.sprite_override` fields — but DO NOT add a new renderer wiring for sprite_override. Per RESEARCH §"set_sprite_override schema" the override is shipped as a STUB in 06.7 (handler + schema + state field; no consumer wiring in renderer because special poses are post-MVP). Document this with a single-line comment in bindHandlers near the player onChange callback: `// 06.7: sprite_override is stubbed — renderer consumer added when PAR-* phases ship the special-pose sprites.`
      - For `anim_state`, similarly: the existing facing-based sprite selection (06.4 D-58c precedent) already covers the 16 base poses; sending `anim_state` on the wire is forward-compat for future poses. No renderer change required in 06.7 per the PATTERNS.md §"Pitfall 8" note. Verify with a comment.

      GameScene test (`apps/client/src/__test__/game-scene.test.ts`):
      - Update the existing mock at lines ~68-92 (`reconcileInstances` + `ReconcileEngine` mock) to also include a PositionDispatcher mock if the test exercises that code path. If the existing game-scene.test.ts does not touch the dispatcher scheduling, this update is a no-op — just confirm the test still passes after the GameScene wiring edits.
      - If the test breaks because the new positionDispatcher field is undefined under the mock, add a corresponding `vi.mock('../prediction/position-dispatcher.js', ...)` block mirroring the existing ReconcileEngine mock.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/game-scene.test.ts src/__test__/colyseus-client.test.ts</automated>
    </verify>
    <done>GameScene instantiates + schedules PositionDispatcher inside the existing 30 Hz `override update` accumulator loop (lines 1166-1206), immediately after predictTick at line 1168; reconnect routes new room to the dispatcher; handshake sends protocol_version=4; bindHandlers documented for the stubbed sprite_override path; existing game-scene + colyseus-client tests still pass (with mock updates if needed).</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "PositionDispatcher" apps/client/src/scenes/GameScene.ts` matches at least 2 lines (import + instantiation)
      - Source assertion (W4 — scheduler hook placement): `grep -nE "positionDispatcher\??\.sendTick\(_time\)" apps/client/src/scenes/GameScene.ts` matches at least one line; the surrounding context (read with offset/limit) shows the call lives INSIDE the `while (this.simTickAccumulator >= SIM_TICK_MS)` loop body, after the `this.prediction.predictTick(...)` call. Specifically: `awk '/override update\(_time/,/^\s*\}$/' apps/client/src/scenes/GameScene.ts | grep -c "positionDispatcher"` ≥ 1
      - Source assertion: `grep -nE "positionDispatcher.*setRoom" apps/client/src/scenes/GameScene.ts` matches at least one line (reconnect wired)
      - Source assertion: handshake value is 4 or sourced from PROTOCOL_VERSION — either `grep -nE "protocol_version: ?4" apps/client/src/net/colyseus-client.ts` matches, OR `grep -nE "protocol_version: ?PROTOCOL_VERSION" apps/client/src/net/colyseus-client.ts` matches AND `grep -n "PROTOCOL_VERSION" packages/protocol/src/version.ts | grep -c '= 4'` ≥ 1 (Plan 01 already verified the constant)
      - Source assertion: NO literal `protocol_version: 3` remains in apps/client — `grep -rn "protocol_version: ?3" apps/client/src` returns 0 matches
      - Source assertion: 06.7 sprite_override stub comment exists — `grep -nE "06.7.*sprite_override.*stub" apps/client/src/net/colyseus-client.ts` matches at least one line
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/game-scene.test.ts src/__test__/colyseus-client.test.ts` exits 0
      - Regression assertion: `pnpm -C apps/client exec vitest run` (full client suite) exits 0
      - Build assertion: `pnpm -C apps/client exec tsc --noEmit` exits 0
      - Trace assertion: `pnpm trace:check` does not regress REQ-CLI-04 / REQ-CLI-08 stages
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → Colyseus `room.send('position_update', ...)` | Client is the source of its own position truth (per D-13 carve-out); server zod-validates the envelope only |
  | GameScene → ReconcileEngine | INTRA-process; no untrusted data. Self-path is now provably no-op; remote path preserved (W7 lock) |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-08 | Tampering (regression) | A future refactor accidentally re-enables ReconcileEngine.onServerSnapshot for self (re-introducing the diagonal-stop ~10 px drift bug from operator UAT 2026-05-16) | mitigate | The D-04 positive-guard test in `reconciler.test.ts` asserts `setPosition` + `tweenTo` are NEVER called when `isSelfPlayer=true`. The reconciler.ts file header documents the invariant. A future revert would either fail the test or require deleting the test (which is auditable). RESEARCH §"Pitfall 1". |
  | T-06.7-08b | Tampering (regression) | A future refactor accidentally collapses the remote-player branch of onServerSnapshot to a no-op, breaking remote-player rendering that the 06.7 operator UAT does not exercise | mitigate | W7 lock: remote-path lerp/threshold tests in `reconciler.test.ts` are RETAINED with explicit `isSelfPlayer=false`. Removing them would require deleting visible test cases — auditable. `DIVERGENCE_THRESHOLD_PX` constant is intentionally NOT deprecated. |
  | T-06.7-09 | Tampering (Phaser race) | An in-flight Phaser tween targeted at the local sprite races with predictor.predictTick's setPosition write, producing visual judder | mitigate / accept | Per RESEARCH §"Pitfall 6": if any GameScene code still calls `tweenTo` against the local sprite outside the now-noop reconciler, add a defensive `scene.tweens.killTweensOf(localSprite)` before the predictor write. If no such call path exists (the only previous caller was the reconciler self-path, now removed), the race is gone by construction. Task 3 documents the choice in a code comment. |
  | T-06.7-10 | DoS (client-side) | Bug in scene scheduler causes PositionDispatcher.sendTick to fire faster than 30 Hz, exceeding server rate-limit (Plan 02 token bucket = 35 tokens/sec / burst 60) | mitigate (server-side) | Server-side `rateLimitOrDrop` drops the excess; on the client, the dispatcher inherits the scene's 30 Hz scheduler — bypassing that scheduler is the only way to overshoot, and that would require a bug, not an exploit. Operator dashboard plots `rate_limit_dropped` for `msg_type='position_update'` per RESEARCH §"Pitfall 3" so a runaway client is observable. The PRE-RESOLVED hook at GameScene.ts:1166-1206 anchors the dispatcher to the SAME accumulator loop that drives predictTick — eliminating timer-drift as a misfire vector. |
  | T-06.7-11 | Tampering | A malicious client constructs a Direction value not in the FACING_OCTANT enum and passes it to `packAnimState`, producing `NaN` or `undefined` on the wire | mitigate | TypeScript types restrict the call site; zod on the server rejects out-of-enum facing. Even if a hostile fork bypasses TypeScript, the zod gate on the server (Plan 02) is the actual security boundary. Client-side packing produces a number (or `undefined | 0x08` which is `NaN`), which zod's `.int().min(0).max(255)` rejects. |

  **Doctrine cross-reference (D-13):** Plan 04 codifies the trust-model split in CLAUDE.md. This plan implements the client side of that split — self-player position lives on the client; the server is a write-through store. The D-04 positive guard in reconciler.test.ts is the regression backstop. The W7 remote-symmetry lock prevents an unrelated remote-rendering regression slipping in under the cover of the self-flip.
  </threat_model>

  <verification>
  - `pnpm -C apps/client exec vitest run` green for the full client unit suite (position-dispatcher.test.ts + reconciler.test.ts + game-scene.test.ts + colyseus-client.test.ts + others)
  - `pnpm -C apps/client exec tsc --noEmit` clean
  - `grep -rn "protocol_version: ?3" apps/client/src` returns 0 matches (no v3 literal remnant)
  - D-04 positive guard test present and passing
  - D-06 idle-cadence test present and passing
  - D-57b + D-58b retirement tombstones present in reconciler.test.ts; remote-path lerp/threshold tests retained with `isSelfPlayer=false`
  - PositionDispatcher.sendTick wired inside the 30 Hz accumulator loop at GameScene.ts:1166-1206, not on a new timer
  - `pnpm trace:check` does not regress REQ-CLI-04 / REQ-CLI-08
  </verification>

  <success_criteria>
  - Client streams c2s.position_update at the sim-tick cadence with all 9 fields correctly populated and int16-quantized, including idle frames (D-06)
  - Self-player reconciliation is provably absent — D-04 acceptance criterion satisfied
  - Remote-player reconciliation behavior is preserved — W7 lock satisfied
  - Phase 06.7-02's two-client integ (when run against this client) shows the end-to-end roundtrip with diagonal-stop drift NOT manifesting
  - Plan 04 operator UAT is unblocked (the full client+server stack runs end-to-end on staging)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-SUMMARY.md`. Capture:
  - Confirmation that the GameScene scheduler invocation reuses the existing 30 Hz boundary at lines 1166-1206 (no new timer) — record the actual post-edit line number for the `positionDispatcher?.sendTick(_time)` call.
  - Whether colyseus-client.ts now sources `protocol_version` from the constant (preferred) or a literal (acceptable but flag for cleanup).
  - Whether the Phaser tweens.killTweensOf defensive call was added or determined unnecessary (with line refs).
  - The list of D-57b/D-58b tests removed (file:line refs to the deletion) so a future archaeologist sees the deliberate retirement.
  - Confirmation that the remote-path lerp/threshold tests are retained with explicit `isSelfPlayer=false` (W7 lock).
  - Confirmation that the D-06 idle-cadence guard test passes against the implementation (the dispatcher does NOT skip idle frames).
  </output>
  </content>
  </invoke>
  ---
  #### 06.7-04-PLAN.md
  ---
  phase: 06.7
  plan: 04
  type: execute
  wave: 3
  depends_on:
    - 06.7-02
    - 06.7-03
  files_modified:
    - CLAUDE.md
    - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
    - .planning/STATE.md
    - .planning/ROADMAP.md
  autonomous: false
  requirements:
    - REQ-CLI-08
    - REQ-SRV-03
    - REQ-SRV-04
    - REQ-CLI-04
  must_haves:
    truths:
      - "CLAUDE.md Hard Rule 1 contains the D-13 narrow movement carve-out — clients are authoritative for {position, velocity, facing, anim_state}; server stays authoritative for chat origin, inventory, room transitions, persistence"
      - "Scores/combat are NOT in the Hard Rule 1 example list (REBNO has none — per D-13)"
      - "Hard Rule 1 carries the literal [doc->REQ-SRV-04] tag (W5 resolution — REQ-SRV-04 = 'Server is authoritative — clients send intent, server emits state', the doctrine anchor in traceable-reqs.toml)"
      - "06.7-HUMAN-UAT.md exists and is runnable by an operator on staging — closes diagonal-stop drift + simulated-packet-delay hitching + two-player smoke + idle-cadence 30 Hz network panel observation (W3 Test 6)"
      - "Operator UAT signed off (checkpoint:human-verify gate passes)"
      - "STATE.md reflects phase completion; ROADMAP.md Phase 06.7 entry shows plans as [x] and the phase as ready for verification"
    artifacts:
      - path: "CLAUDE.md"
        provides: "Hard Rule 1 narrow movement carve-out (D-13) tagged [doc->REQ-SRV-04]"
        contains: "CLIENT-AUTHORITATIVE"
      - path: ".planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md"
        provides: "Operator UAT script — diagonal-stop drift + dropped-packet hitching + two-player smoke + D-06 idle-cadence network-panel observation"
        contains: "Diagonal-stop"
      - path: ".planning/STATE.md"
        provides: "Phase 06.7 status update"
        contains: "06.7"
      - path: ".planning/ROADMAP.md"
        provides: "Phase 06.7 plans marked complete + UAT signed-off"
        contains: "06.7-01-PLAN.md"
    key_links:
      - from: "CLAUDE.md §Hard Rules Rule 1"
        to: "client-trust movement carve-out paragraph"
        via: "doctrine update per D-13 + literal [doc->REQ-SRV-04] tag"
        pattern: "CLIENT-AUTHORITATIVE"
      - from: "06.7-HUMAN-UAT.md"
        to: "Operator sign-off section"
        via: "explicit pass/fail per test (1-6)"
        pattern: "Sign-off"
  ---

  <objective>
  Land the doctrine change codifying the trust split (CLAUDE.md Hard Rule 1 narrow carve-out per D-13, anchored with the literal `[doc->REQ-SRV-04]` tag per W5 resolution), ship the operator UAT script that closes the two motivating bugs from operator UAT 2026-05-16 (diagonal-stop drift + dropped-packet hitching) AND adds a Test 6 idle-cadence network-panel observation (W3 — the human counterpart to the automated D-06 idle-cadence guard in Plan 03), run the UAT on staging (operator checkpoint), and update STATE/ROADMAP to reflect phase completion.

  Purpose: D-13 / D-12 — doctrine catches up to the implementation; staging-only hard-cut deployment validates the new model in place; rollback path documented (D-14 = `/gsd-undo` on phase commits).
  Output: 1 doctrine edit (with W5-pinned tag) + 1 new UAT script (with W3-added Test 6) + 1 operator checkpoint task + STATE/ROADMAP updates. The CLAUDE.md edit IS a Phase 06.7 deliverable per the planning-context "CLAUDE.md edit task IS a deliverable" instruction.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-02-PLAN.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-PLAN.md
  @CLAUDE.md
  @.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md

  <interfaces>
  <!-- Doctrine target — CLAUDE.md Hard Rules §1: -->

  Current text (locate via Grep — `grep -n "Server-authoritative" CLAUDE.md`):
  > 1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.

  Replacement per CONTEXT D-13 + PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out":
  > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent
  >    for everything EXCEPT player movement. Server emits state. Never trust client
  >    identity, chat origin, inventory, room transitions, or persistence — these
  >    are server-authoritative. **Movement (position, velocity, facing, anim_state)
  >    is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported
  >    state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger)
  >    is deferred to Phase 06.8. [doc->REQ-SRV-04]

  Notes:
  - D-13 LOCKED scope: ONE paragraph edit. No tabular restructure (deferred).
  - D-13 LOCKED scope: DROP "scores, combat" from the rule example list — REBNO has none.
  - **W5 RESOLVED:** the literal tag is `[doc->REQ-SRV-04]`. Confirmed by inspection of `traceable-reqs.toml` line 132-134: `REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"` — this is the EXACT doctrine anchor for Hard Rule 1. The executor does NOT need to grep or choose; it is pinned here.

  <!-- UAT precedent — `.planning/phases/06.1-.../06.1-HUMAN-UAT.md` structure: -->
  - ## Preconditions (staging URL, accounts, browser)
  - ## Operator Test 1, Test 2, ... (numbered steps + expected vs observed columns)
  - ## Sign-off section (operator name, date, pass/fail, notes)
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: CLAUDE.md Hard Rule 1 narrow movement carve-out (D-13) — tagged [doc->REQ-SRV-04]</name>
    <files>CLAUDE.md</files>
    <read_first>
      - CLAUDE.md (FULL — Hard Rules section, specifically Rule 1; current text is "1. **Server-authoritative.** Clients send intent..."; check the broader context to ensure no other Hard Rule depends on the current phrasing)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out (doctrine — D-13)" (exact replacement text)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-13 (LOCKED scope: paragraph edit only, no table restructure, drop scores/combat)
      - traceable-reqs.toml (verified by the planner at revision time — REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"; this IS the Hard Rule 1 doctrine anchor)
    </read_first>
    <action>
      Locate Hard Rule 1 in CLAUDE.md (line currently reading `1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.`).

      Replace the rule body with the exact text below (also reproduced in PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out"). **The literal `[doc->REQ-SRV-04]` tag is REQUIRED at the end of the paragraph (W5 resolution):**

      > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8. [doc->REQ-SRV-04]

      The tag MUST be the literal `[doc->REQ-SRV-04]`. The planner verified at revision-iteration-1 time that `traceable-reqs.toml` line 132-134 declares `REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"` with `required_stages = ["doc", "impl", "unit", "int"]` — REQ-SRV-04 is the doctrine anchor for Hard Rule 1, no further grep / choice required. Do NOT substitute `REQ-SRV-03` (which anchors "apps/server runs Node 22 + Colyseus 0.17 with one Room class") nor `REQ-CLI-04` (which anchors client-prediction discipline) — those have different titles and would muddle traceability.

      Do NOT modify any other Hard Rule. Do NOT add a table. Do NOT add Phase 06.8 details beyond the one-sentence deferral reference already in the paragraph.

      Run `pnpm trace:check` post-edit to confirm the new tag is recognised and no `undeclared_id` finding is introduced (REQ-SRV-04 is already declared in traceable-reqs.toml so the doc-stage row should now show `doc` covered).
    </action>
    <verify>
      <automated>grep -nE "CLIENT-AUTHORITATIVE" CLAUDE.md && grep -nE "\[doc->REQ-SRV-04\]" CLAUDE.md && pnpm trace:check</automated>
    </verify>
    <done>Hard Rule 1 reads with the D-13 carve-out language; the literal `[doc->REQ-SRV-04]` tag anchors the paragraph; `pnpm trace:check` passes; REQ-SRV-04 doc-stage row shows covered.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "Server-authoritative \(with narrow movement carve-out\)" CLAUDE.md` matches exactly one line
      - Source assertion: `grep -n "CLIENT-AUTHORITATIVE" CLAUDE.md` matches at least one line
      - Source assertion: `grep -n "position, velocity, facing, anim_state" CLAUDE.md` matches the carve-out scope sentence
      - Source assertion: `grep -n "scores" CLAUDE.md | grep -i "rule" -B5 | grep -c "Hard Rule 1"` returns 0 — i.e. the word "scores" no longer appears in Hard Rule 1 (per D-13 drop). (Scores may appear elsewhere in the file; the assertion scopes to Hard Rule 1 context. If grep glue is fragile, the simpler check: read Hard Rule 1 directly and confirm "scores" is absent.)
      - Source assertion (W5 — pinned tag): `grep -nE "\[doc->REQ-SRV-04\]" CLAUDE.md` matches at least one line in the Hard Rule 1 paragraph
      - Trace assertion: `pnpm trace:check` exits 0 with no `undeclared_id` or `parse_error` findings for the new tag; REQ-SRV-04 doc-stage row is covered by the new CLAUDE.md tag
    </acceptance_criteria>
  </task>

  <task type="auto">
    <name>Task 2: Create 06.7-HUMAN-UAT.md operator script (with W3 Test 6 — idle-cadence network-panel observation)</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <read_first>
      - .planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md (FULL — structure precedent: preconditions, numbered operator tests, sign-off section)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §"Motivating bugs" (D-02 — diagonal-stop ~10 px shift; dropped-packet hitching) + §D-06 (steady 30 Hz cadence; idle frames still emit)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"06.7-HUMAN-UAT.md"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Validation Architecture" row "REQ-CLI-08 manual-only"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-VALIDATION.md (the new row: "D-06 idle cadence guard — PositionDispatcher.sendTick emits valid payload when getLocalState returns {vx:0, vy:0}" — the automated half lives in Plan 03 Task 1; this human-UAT Test 6 is the manual half)
    </read_first>
    <action>
      Create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`. Tag the file `[doc->REQ-CLI-08]` near the top. Structure mirrors `.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md`:

      ### Preconditions
      - Staging deployed with phase 06.7 commits (server + client)
      - PROTOCOL_VERSION = 4 confirmed live
      - Two browser sessions (Chrome) with two distinct operator accounts authed
      - Chrome DevTools Network conditions panel accessible (for packet-delay simulation AND for Test 6 frame-count observation)
      - Operator IP allowlisted on staging (per memory note 2026-05-08 — re-verify session IP)

      ### Test 1: Diagonal-stop drift bug closed (D-02a)
      Reproduction steps from the original 2026-05-16 UAT:
      - Step 1: log in, navigate to BNCentral spawn
      - Step 2: press W+D (UR diagonal) for 2 seconds
      - Step 3: release BOTH keys simultaneously
      - Expected: sprite stops at its current pixel position with NO horizontal shift (no ~10 px snap right or left)
      - Observed: ___ (operator fills in)
      - Pass/Fail: ___

      ### Test 2: Dropped-packet hitching closed (D-02b)
      - Step 1: open Chrome DevTools → Network → Throttling → "Slow 3G" (or custom: 5% packet loss / 150ms RTT)
      - Step 2: hold W (run north) for 5 seconds
      - Expected: continuous smooth movement (each position_update is self-describing per the 06.7 model; single dropped packet invisible at 30 Hz)
      - Observed: ___
      - Pass/Fail: ___
      - Note: if hitching appears, capture HAR + screenshot; this is the regression-on-this-bug signal.

      ### Test 3: Two-player smoke (REQ-CLI-08 hard milestone — regression confirm)
      - Step 1: Operator A logs in on Browser 1; Operator B logs in on Browser 2; both join BNCentral
      - Step 2: A and B see each other's sprites
      - Step 3: A walks in a square (W, D, S, A for 1 sec each); B observes A moving smoothly (no rubber-band, no diagonal-stop drift visible on remote view)
      - Step 4: B walks in a square; A observes same smoothness
      - Step 5: Both send chat messages; chat appears with correct origin attribution
      - Expected: all four observation checks pass
      - Observed: ___
      - Pass/Fail: ___

      ### Test 4: PROTOCOL_VERSION 3 client rejected (deploy-ritual verification — D-12)
      - Step 1: Open the staging URL with `?protocol_version_override=3` (or however the dev-tooling allows forcing a downgraded handshake — confirm the override mechanism with the codebase before authoring; if no override exists, skip this test and note that automated integ in Plan 02 covers it)
      - Step 2: Attempt to join
      - Expected: connection rejected with PROTOCOL_VERSION_MISMATCH (close 4400)
      - Observed: ___
      - Pass/Fail: ___

      ### Test 5: Rollback drill (D-14 — `/gsd-undo` works)
      - Step 1: Capture current staging commit SHA
      - Step 2: Do NOT actually run /gsd-undo (this is a paper drill); verify the runbook references the commit SHAs of plans 01-04 as the rollback target
      - Expected: documented rollback target is the 4 phase commits
      - Observed: ___
      - Pass/Fail: ___

      ### Test 6: Idle-cadence steady 30 Hz network confirm (D-06 — REQ-CLI-04 — W3 addition)
      Closes the manual half of the D-06 validation row from 06.7-VALIDATION.md. The automated half is the unit test in Plan 03 Task 1 (`apps/client/src/__test__/position-dispatcher.test.ts` — "D-06 06.7 — sendTick emits a valid payload when player is idle").
      - Step 1: log in to BNCentral; do NOT press any movement keys
      - Step 2: open Chrome DevTools → Network panel → filter for WS frames on the Colyseus connection (the websocket carrying `position_update`)
      - Step 3: leave the player idle for 5 seconds, watching the websocket frames panel
      - Expected: approximately 150 `position_update` frames over the 5-second window (30 Hz × 5 s = 150 frames; acceptable range ~120–180 to allow for browser scheduler jitter). Each frame carries the same x/y and vx=0/vy=0. The cadence is STEADY — no gaps longer than ~100 ms.
      - Observed (rough frame count over 5 s): ___
      - Observed (any gaps > 100 ms?): ___
      - Pass/Fail: ___
      - Note: if frame count is dramatically lower (e.g. < 30 over 5 s), the dispatcher is gating on movement — a D-06 violation; capture the websocket trace + the dispatcher source diff and route to gap-closure.

      ### Sign-off
      - Operator: ___
      - Date: ___
      - Overall pass/fail: ___
      - Phase 06.7 ready for `/gsd-verify-work`: yes / no
      - Open issues (if any): ___

      Tag the file body with at least one `[doc->REQ-CLI-08]` AND one `[doc->REQ-CLI-04]` (the D-06 idle-cadence row in 06.7-VALIDATION.md maps to REQ-CLI-04; Test 6 closes the manual half of that row). The file is the manual-only complement to the automated CLI-08 integ from Plan 02 + the D-06 idle-cadence unit from Plan 03 Task 1 — together they close REQ-CLI-08 and the D-06 row of REQ-CLI-04 for this phase.
    </action>
    <verify>
      <automated>test -f .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -cE "^### Test [1-6]:" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -n "doc->REQ-CLI-08" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -n "doc->REQ-CLI-04" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
    </verify>
    <done>Operator UAT script exists with 6 tests (1-5 original + 6 = W3 idle-cadence network-panel observation) + sign-off section; both REQ-CLI-08 and REQ-CLI-04 doc tags present; structure mirrors the 06.1 precedent.</done>
    <acceptance_criteria>
      - File exists: `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`
      - Source assertion: `grep -cE "^### Test [1-6]:" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` returns ≥ 6 (Test 1..Test 6 sections present — W3 Test 6 included)
      - Source assertion: `grep -n "Diagonal-stop" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion: `grep -n "Dropped-packet\|packet loss\|packet delay" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion: `grep -n "Two-player\|two-player" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion (W3 — Test 6 idle-cadence observation): `grep -nE "Idle-cadence|idle.*5 sec|30 position_update frames|150 .*frames" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line in Test 6
      - Source assertion: `grep -n "Sign-off" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Tag assertion: `grep -c "doc->REQ-CLI-08" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` ≥ 1
      - Tag assertion (W3 — REQ-CLI-04 D-06 row anchored): `grep -c "doc->REQ-CLI-04" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` ≥ 1
      - Trace assertion: `pnpm trace:check` exits 0
    </acceptance_criteria>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <name>Task 3: Operator UAT checkpoint — run 06.7-HUMAN-UAT.md on staging and sign off</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <read_first>
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md (just authored in Task 2)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-12 (hard cut on staging) §D-14 (rollback path)
    </read_first>
    <action>
      Pause for operator. The operator opens 06.7-HUMAN-UAT.md, executes the SIX tests on staging in sequence (Tests 1-5 + the W3 Test 6 idle-cadence observation), records Observed + Pass/Fail per test, fills in the Sign-off section, and commits the updated UAT file. Claude does NOT automate this task — the entire point is operator-feel verification of the two motivating bugs (D-02a diagonal-stop drift + D-02b dropped-packet hitching) that no automated test captures, plus the D-06 idle-cadence behavior observed via DevTools (Test 6) which the unit test in Plan 03 cannot observe on a real browser. See <what-built> and <how-to-verify> below for the operator-facing instructions.
    </action>
    <what-built>
      Phase 06.7 ships the client-trust movement model end-to-end:
      - Plan 01: protocol surface (cPositionUpdateSchema, cSetSpriteOverrideSchema, packAnimState/unpackAnimState, PlayerState fields, PROTOCOL_VERSION=4)
      - Plan 02: server handlers + tickLoop platforms-only + protocol v4 handshake test
      - Plan 03: PositionDispatcher (with D-06 idle-cadence guard test) + reconciler self-noop (D-04) + protocol_version=4 handshake + remote-symmetry preserved (W7 lock)
      - Plan 04 Task 1: CLAUDE.md Hard Rule 1 narrow carve-out (D-13) — tagged `[doc->REQ-SRV-04]` (W5)
      - Plan 04 Task 2: Operator UAT script — 6 tests including Test 6 (D-06 idle-cadence — W3)

      Pre-checkpoint sanity: ensure the latest staging deploy includes plans 01+02+03 commits BEFORE running this checkpoint. If staging is behind, kick off a deploy first.
    </what-built>
    <how-to-verify>
      1. Open `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`.
      2. Execute Tests 1-6 in sequence on staging.
         - For each test, record the Observed and Pass/Fail values.
         - Test 6 specifically asks for a websocket frame count over 5 idle seconds — leave the player idle and watch the DevTools Network → WS panel. Expected ~150 frames (30 Hz × 5 s); acceptable 120–180.
         - If ANY test fails: do NOT sign off; capture artifacts (HAR, screenshot, console log); document in the UAT file's "Open issues" section; bring the failure back to the planner via gap-closure flow.
         - If all tests pass: fill in the Sign-off section (operator name, date, "Pass") and commit the updated UAT file.
      3. After sign-off, confirm:
         - `pnpm trace:check` passes (and REQ-SRV-04 doc row now shows the CLAUDE.md tag covering it)
         - `pnpm -r test` (full workspace test) is green — including the new D-06 idle-cadence unit
         - `pnpm -r typecheck` is clean
         - The four phase commits are pushed to staging branch
      4. Type "approved" (or describe failures + recommended next step).
    </how-to-verify>
    <verify>
      <automated>grep -nE "Overall pass/fail: (Pass|pass|PASS)" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -nE "Operator: \S" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
    </verify>
    <done>Operator has filled in the Sign-off section of 06.7-HUMAN-UAT.md with their name + date + an overall Pass; all six tests recorded as Pass or with documented open issues that are non-blocking for this phase; commit landed.</done>
    <resume-signal>Type "approved" to mark UAT passed and advance to Task 4 (STATE/ROADMAP wrap), or paste failure details + recommended next step (gap-closure plan / rollback / other).</resume-signal>
    <acceptance_criteria>
      - Source assertion: `grep -nE "Overall pass/fail: (Pass|pass|PASS)" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches the post-sign-off line
      - Source assertion: every `### Test N:` block (N=1..6) has a filled-in `Observed:` value (not just the `___` placeholder) — visual scan of the UAT file
      - Behavior assertion: operator typed "approved" in the chat or paste-back, confirming gate passed
      - Regression assertion: `pnpm -r test && pnpm -r typecheck && pnpm trace:check` all exit 0 at sign-off time
    </acceptance_criteria>
  </task>

  <task type="auto">
    <name>Task 4: Update STATE.md + ROADMAP.md to reflect Phase 06.7 completion</name>
    <files>.planning/STATE.md, .planning/ROADMAP.md</files>
    <read_first>
      - .planning/STATE.md (current phase status section)
      - .planning/ROADMAP.md (Phase 06.7 entry @ lines 200-214; the existing plans list is "TBD")
      - The completed 06.7-01/02/03/04 SUMMARY files (produced by each plan's <output> stanza)
    </read_first>
    <action>
      Update `.planning/STATE.md`:
      - Move Phase 06.7 to a "completed" or "verification-pending" status as appropriate to the project's STATE convention.
      - Record the four phase commit SHAs (one per plan) with one-line summaries.
      - Note the operator UAT pass-date from Task 3.
      - Add the deferred-to-06.8 items: server-side legality check, speed-cap, walkable-grid sanity, fall reset (per CONTEXT D-11 + Deferred Ideas).
      - Add the deferred-to-06.9-cleanup items: c2s.input + heldInputs removal (per RESEARCH §Open Question 3).

      Update `.planning/ROADMAP.md`:
      - In the Phase 06.7 section (around line 200-214), replace the "TBD" plan list with the four phase plans, all checked off:
        ```
        Plans:
        - [x] 06.7-01-PLAN.md — Wave 1 — Protocol surface: cPositionUpdateSchema + cSetSpriteOverrideSchema + packAnimState + PlayerState fields + PROTOCOL_VERSION 3→4 (REQ-SRV-03, REQ-CLI-04, REQ-CLI-08)
        - [x] 06.7-02-PLAN.md — Wave 2 — Server: position_update + set_sprite_override handlers + tickLoop platforms-only + protocol-v4 handshake integ (REQ-SRV-03, REQ-SRV-14, REQ-CLI-08)
        - [x] 06.7-03-PLAN.md — Wave 2 — Client: PositionDispatcher (+D-06 idle-cadence guard) + reconciler self-noop (D-04) + remote symmetry preserved (W7) + protocol_version=4 handshake (REQ-CLI-04, REQ-CLI-08)
        - [x] 06.7-04-PLAN.md — Wave 3 — CLAUDE.md Hard Rule 1 carve-out (D-13, [doc->REQ-SRV-04]) + operator HUMAN-UAT (6 tests incl. D-06 idle-cadence) + STATE/ROADMAP wrap (REQ-CLI-08, REQ-SRV-03, REQ-SRV-04, REQ-CLI-04)
        ```
      - Update **Plans:** TBD line to `**Plans:** 4 plans (all complete, operator UAT signed off YYYY-MM-DD)`.
      - DO NOT mark the phase as `[x]` at the roadmap top-level until `/gsd-verify-work 06.7` confirms — the wrap-up task stops at "complete-pending-verify".

      Add a brief line near the bottom of the ROADMAP Phase 06.7 entry noting the future 06.8 dependency: "Anti-cheat (illegal-position fall trigger + speed-cap + walkable-grid sanity) deferred to candidate Phase 06.8 per CONTEXT D-11."
    </action>
    <verify>
      <automated>grep -cE "^\- \[x\] 06\.7-(01|02|03|04)-PLAN\.md" .planning/ROADMAP.md && grep -n "06.7" .planning/STATE.md | head -5</automated>
    </verify>
    <done>STATE + ROADMAP reflect Phase 06.7 complete-pending-verify; the four plans are checked off in ROADMAP; deferred 06.8 items are documented.</done>
    <acceptance_criteria>
      - Source assertion: `grep -cE "^\- \[x\] 06\.7-(01|02|03|04)-PLAN\.md" .planning/ROADMAP.md` returns exactly 4
      - Source assertion: `grep -n "operator UAT signed off" .planning/ROADMAP.md` matches a line in the Phase 06.7 section
      - Source assertion: `grep -n "06.7" .planning/STATE.md` matches at least one line (phase reflected in current state)
      - Source assertion: `grep -n "candidate Phase 06.8" .planning/ROADMAP.md` matches the deferred-item annotation
      - Trace assertion: `pnpm trace:check` exits 0 (no doc-stage regression)
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Doctrine (CLAUDE.md) → Future planners / agents | Hard Rule 1 IS the trust-boundary specification; doctrine drift is the long-term Tampering risk |
  | Operator UAT script → Operator | Manual-only path; operator's judgment closes the feel-based regressions automated tests can't |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-12 | Tampering (doctrine drift) | A future planner accidentally re-asserts "Never trust client positions" in a new doctrine paragraph, contradicting Hard Rule 1's carve-out | mitigate | Hard Rule 1 paragraph explicitly states "CLIENT-AUTHORITATIVE as of Phase 06.7" with a phase reference; `pnpm trace:check` enforces the doc-stage tag exists; the literal `[doc->REQ-SRV-04]` tag anchors the paragraph to the traceability table. Future doctrine edits should NOT re-tag REQ-SRV-04 from a contradictory paragraph; if they do, a reviewer notices the duplication and reconciles. |
  | T-06.7-13 | Repudiation (operator UAT) | Operator signs off without actually running the tests | accept | Solo-developer workflow; the operator is the user. Trust model degrades at the doctrine layer if the operator self-deceives. Mitigation is structural (numbered tests 1-6 with explicit pass/fail per item, including a frame-count observation in Test 6 that is hard to fake without artifacts); no further enforcement. |
  | T-06.7-14 | Information Disclosure (rollback drill) | The phase commit SHAs documented in STATE.md leak in a way that helps attackers — unlikely but possible if the repo accidentally goes public | accept | Per CLAUDE.md Hard Rule 8, the repo stays private through Phase 7. Phase commit SHAs are public-safe (no secrets in commit content); the rollback drill is purely procedural. |

  **Doctrine continuity (D-13 cross-check):** The CLAUDE.md edit in Task 1 closes the doctrine loop opened by Plans 01-03 (which implemented the trust split but did not codify it). Without this edit, a future planner reading only CLAUDE.md would mis-architect (e.g. add server-side position derivation that double-writes player.x). Task 1 is therefore as load-bearing as the implementation plans. **W5 pin:** the tag is `[doc->REQ-SRV-04]`, no executor ambiguity.

  **Operator UAT-driven safety:** Three of the motivating outcomes (D-02a diagonal-stop drift, D-02b dropped-packet hitching, D-06 idle-cadence steady 30 Hz) are operator-feel or browser-observable regressions that no headless automated test captures cleanly. Test 1 + Test 2 + Test 6 close these explicitly; without the UAT, "the bug is closed" / "the cadence holds in a real browser" is unverifiable.
  </threat_model>

  <verification>
  - CLAUDE.md Hard Rule 1 reads with D-13 language and the literal `[doc->REQ-SRV-04]` tag (W5)
  - `pnpm trace:check` passes (no undeclared_id from the new doc tag; REQ-SRV-04 doc-stage row now covered)
  - 06.7-HUMAN-UAT.md present and complete — 6 tests including Test 6 (D-06 idle-cadence, W3)
  - Operator checkpoint (Task 3) returns "approved" — or returns failure detail that gets escalated to gap-closure
  - STATE.md / ROADMAP.md updated post-sign-off
  - `pnpm -r test` + `pnpm -r typecheck` green across the workspace (regression confirmation against the combined Plan 01+02+03 deltas — including the new D-06 idle-cadence unit)
  </verification>

  <success_criteria>
  - Doctrine, implementation, and observability are aligned: CLAUDE.md describes what the code does (with the W5-pinned [doc->REQ-SRV-04] tag); the code does what the operator UAT demands (including the W3 Test 6 idle-cadence observation); the operator UAT confirms the two motivating bugs from operator UAT 2026-05-16 are closed AND that D-06 holds in a real browser
  - /gsd-verify-work 06.7 has everything it needs (4 plans complete, operator UAT signed off, traceable-reqs clean, full test suite green)
  - The deferred-to-06.8 items are documented so a future planner has a starting point
  - The deferred-to-06.9 cleanup items (heldInputs vestigial, cInputSchema vestigial) are also documented
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-04-SUMMARY.md`. Capture:
  - Final CLAUDE.md Hard Rule 1 text (exact post-edit form, including the `[doc->REQ-SRV-04]` tag) for archival
  - Operator UAT outcome (date, pass/fail per test 1-6, any captured artifacts including the Test 6 frame-count value)
  - The four phase commit SHAs for the rollback target
  - The deferred items list for 06.8 (anti-cheat) and 06.9-cleanup (heldInputs)
  - A one-line recommendation: ready for /gsd-verify-work 06.7
  </output>
  </content>
  </invoke>
  ## Review Instructions

  Analyze the plans and provide:
  1. **Summary** — one-paragraph assessment
  2. **Strengths** — bullet points
  3. **Concerns** — bullets with severity (HIGH/MEDIUM/LOW)
  4. **Suggestions** — specific improvements
  5. **Risk Assessment** — overall LOW/MEDIUM/HIGH with justification

  Focus on:
  - Missing edge cases or error handling
  - Dependency ordering issues
  - Scope creep / over-engineering
  - Security (the D-11 acceptance of zero server-side anti-cheat is explicit — flag only if you see a NEW security gap unrelated to D-11)
  - Performance (50 CCU, 30 Hz cadence, Colyseus TCP/WS, single Fly.io machine)
  - Whether the plans actually achieve the phase goal: flip self-player movement to client-trust + reconciler no-op for self, fix diagonal-stop drift + dropped-packet hitching, keep remote symmetry

  Output review in markdown.

first_user_message:
  # Cross-AI Plan Review Request

  You are reviewing implementation plans for a software project phase.
  Provide structured feedback on plan quality, completeness, and risks.

  ## Project Context
  # REBNO — Rebuild BN Online

  ## What This Is

  REBNO is a ground-up modernization of *BN Online*, a late-1990s/early-2000s multiplayer game shipped as a GameMaker 5.3a `.gmd` project. The work extracts the original client and server source from `BN Online Client 5-8.gmd` and `BN Online Master 5-4.gmd`, documents engine + server features, then rebuilds both halves on a modern web stack (TypeScript everywhere, Node server on Fly.io, Phaser 3 *or* PixiJS client running in Chrome) at full feature parity with a modernized UX.

  ## Core Value

  **Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.** Everything else (graphics polish, full feature parity, scaling) flows from that working multiplayer baseline.

  ## Requirements

  ### Validated

  <!-- Brownfield: existing artifacts in repo. Code/wiki present, but nothing new shipped yet. -->

  - ✓ GameMaker 5.3a reverse-engineering wiki (17 docs in `decomp/wiki/`) — existing
  - ✓ Legacy archive of all `.gmd` revisions, server source, Unity remake attempt, Maya assets, audio — existing
  - ✓ Codebase map (`.planning/codebase/`, 7 docs) — existing
  - ✓ Stage 1 (Extraction) complete — Validated in Phase 1: extraction (2026-05-03)
    - `tools/extract-gmd/` TS Node CLI ports `LateralGM GmFileReader` semantics; 157/157 tests
    - `extracted/client-5-8/` (8307 files) + `extracted/server-5-4/` (1144 files) committed; reproducibility byte-identical
    - 13 wire-format bugs uncovered + documented in `decomp/wiki/03-gmd-format.md` errata
    - Sprite/background image data carried as opaque `imageBytes` (Option A pivot — BMP→PNG decode deferred to Phase 6/7 asset-pipeline; EXT-04 acceptance relaxed accordingly)

  ### Active

  #### Stage 1 — Extraction (DONE — Phase 1, 2026-05-03)

  #### Stage 2 — Client analysis (DONE — Phase 2, 2026-05-04)

  #### Stage 3 — Server analysis (DONE — Phase 3, 2026-05-05; status: human_needed — Linux CI verify-phase-3 deferred per HUMAN-UAT.md)

  #### Stage 4 — Server rebuild (NEXT)
  - [ ] Reimplement server in Node + TypeScript with binary WebSocket transport
  - [ ] Refactor as code is ported: identify and eliminate bugs from the original
  - [ ] Migrate original user accounts to a modern hash (bcrypt/argon2) — preserve usernames, force password reset on first login OR transparent re-hash on first successful auth
  - [ ] Authoritative server model — clients never trust each other for game state
  - [ ] Room/level layouts stored server-side and transmitted to client over the wire on room entry, integrity-verified; client bundle ships no static layout data (SRV-13)
  - [ ] Moving-platform live positions deterministic via `packages/game-logic` `step()` and broadcast authoritatively to all clients in the same state-diff envelope as player positions (SRV-14)

  #### Stage 5 — Deploy
  - [ ] Deploy new server to Fly.io with persistent volume + Litestream backup (or Postgres)
  - [ ] CI/CD pipeline: push to main → deploy
  - [ ] Health checks, structured logging, basic ops dashboard

  #### Stage 6 — Client rebuild (MVP)
  - [ ] Web client (Phaser 3 or PixiJS, TypeScript, Vite) running in Chrome
  - [ ] Modernized UX (HiDPI, responsive, keyboard + gamepad input)
  - [ ] **MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server**

  #### Stage 7 — Full parity
  - [ ] All client features from Stage 2 documentation reimplemented
  - [ ] All server features from Stage 3 documentation reimplemented
  - [ ] Original-game test cases pass against new client + server

  ### Out of Scope

  - **Mobile / native ports** — Chrome-first; no iOS, Android, Steam binaries in v1
  - **Engine-level emulation of GameMaker 5.3a** — we reimplement game *logic*, not the GM5 runtime
  - **Re-running the original Windows binary** — preservation belongs in `legacy/`, not the rebuild
  - **Server-side anti-cheat beyond authoritative model** — no behavioral detection in v1
  - **Modding API / user-generated content** — out of scope until parity ships
  - **3D / VR / new gameplay features** — faithful rebuild first, then iterate
  - **Public marketing / asset rights clearance** — license + distribution decision deferred until rebuild is functional (see Key Decisions)

  ## Context

  - **Repo layout** (see `.planning/codebase/STRUCTURE.md`): `decomp/wiki/` holds RE knowledge, `legacy/` holds all original artifacts (audio, maya-project, open-source-release, servers, source-archive, unity-project). Two prior remake attempts exist (`legacy/unity-project`, `legacy/open-source-release`); both can inform the new rebuild but are not the authoritative source — the `.gmd` files are.
  - **Source-of-truth files**: `legacy/open-source-release/BN Online Client 5-8.gmd` (client) and `legacy/open-source-release/BN Online Master 5-4.gmd` (server). Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only.
  - **Engine constraints to overcome** (from wiki): GM5.3a is Delphi 5 / Win32, modern decompilers don't read it, network code is 39dll-only (TCP, no native sockets), assets are ZLIB-compressed inside a monolithic XOR-obfuscated payload appended to the runner stub. Extraction pipeline is documented in `decomp/wiki/15-extraction-pipeline.md`.
  - **Proprietary formats**: `.bno` / `.bnb` save data is bespoke — only readable by walking the extracted GML's `file_bin_*` call order, not via external tools.
  - **Bit-rot / IP risks** flagged in `.planning/codebase/CONCERNS.md` (229 lines) — read before committing scope.

  ## Constraints

  - **Tech stack — server**: Node + TypeScript. Mature ecosystem, best WS tooling (`ws`, `uWebSockets.js`), strong type story for shared client/server packet definitions.
  - **Tech stack — client**: TypeScript + Phaser 3 (preferred) or PixiJS (fallback). Decision deferred to end of Stage 2 once feature surface is known. Must run in Google Chrome, no plugins.
  - **Tech stack — transport**: WebSocket binary frames. Authoritative server, clients send intent, server emits state.
  - **Tech stack — persistence**: TBD in Stage 3 (likely SQLite + Litestream for <50 player target; Postgres if data model demands relational queries).

  ## Phase 06.7: Network model — client-trust + server illegal-position fall trigger
  ### Roadmap Section
  ### Phase 06.7: Network model shift to client-trust + server illegal-position fall trigger (INSERTED)\r\n\r\n**Goal**: Flip the player-movement trust model so client owns its position and server stores client-reported position as authoritative state for movement only. Reconciler becomes a no-op for the self-player (still applied to remote players where server state IS authoritative). Server retains authority over chat origin, inventory, scores, combat math, room transitions. As an anti-cheat backstop, server runs tick-by-tick `isPositionLegal(player.x, player.y, room.walkable_grid)`; if illegal for > N ticks (~250-500 ms grace), server triggers a fall event = forced animation + position reset to last-known-legal or designated respawn. The fall mechanism is the second deliverable (Phase B in the seed) and is what makes client-trust safe at the 50 CCU target. Update CLAUDE.md Hard Rule 1 to clarify movement-specific trust split.\r\n\r\n**Motivation**: Operator UAT 2026-05-16 surfaced choppy movement on staging — root cause is reconciler snap-back from client-prediction-vs-server-truth drift (e.g. diagonal stop produces ~10 px horizontal shift). Core REBNO value depends on predictable movement; original BNO was peer-to-peer 39dll where clients trusted each other's positions, so the design space precedent matches.\r\n\r\n**Depends on:** Phase 06.6\r\n**Requirements**: REQ-SRV-03, REQ-SRV-14, REQ-CLI-04, REQ-CLI-08\r\n**Plans:** 4 plans\n\nPlans:\n- [ ] 06.7-01-PLAN.md — Wave 1 — Protocol surface: cPositionUpdateSchema + cSetSpriteOverrideSchema + packAnimState + PlayerState fields + PROTOCOL_VERSION 3→4 (REQ-SRV-03, REQ-CLI-04, REQ-CLI-08)\n- [ ] 06.7-02-PLAN.md — Wave 2 — Server: position_update + set_sprite_override handlers + tickLoop platforms-only + protocol-v4 handshake integ (REQ-SRV-03, REQ-SRV-14, REQ-CLI-08)\n- [ ] 06.7-03-PLAN.md — Wave 2 — Client: PositionDispatcher + reconciler self-noop (D-04) + protocol_version=4 handshake (REQ-CLI-04, REQ-CLI-08)\n- [ ] 06.7-04-PLAN.md — Wave 3 — CLAUDE.md Hard Rule 1 carve-out (D-13) + operator HUMAN-UAT + STATE/ROADMAP wrap (REQ-CLI-08, REQ-SRV-03)\r\n\r\n**Risks to weigh during discuss/plan:**\r\n- Cheaters can teleport visually (mitigated by fall-reset, but visible to other players briefly until grace expires).\r\n- Two players claiming same position → server arbitration needed for inter-player collisions.\r\n- Position-update spam → rate-limit at game tick rate (30 Hz per Extracted Constants).\r\n- Reconciler refactor + tests need a feature-flag transition path; do NOT bundle this with any in-flight phase.

  ### Requirements Addressed
  REQ-SRV-03, REQ-SRV-14, REQ-CLI-04, REQ-CLI-08 (+ doctrine anchor REQ-SRV-04)

  ### User Decisions (CONTEXT.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger - Context

  **Gathered:** 2026-05-17
  **Status:** Ready for planning

  <domain>
  ## Phase Boundary

  Flip the **self-player movement trust model** from hybrid client-prediction + server-authoritative-reconciler to **client-authoritative position**. Client sends absolute `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}` at 30 Hz; server stores the client-reported state as authoritative and broadcasts to remote players. Reconciler becomes a **no-op for the self-player** (the remote-player path is unchanged — server-state IS authoritative for those).

  Server retains full authority over **non-movement state**: chat origin, inventory, room transitions, persistence. (REBNO has no scores or combat in scope.)

  Motivating bugs (operator UAT 2026-05-16, staging):
  - ~10 px horizontal shift on diagonal stop → root cause is reconciler snap from client-vs-server position drift in `apps/client/src/prediction/reconciler.ts`.
  - Occasional movement hitching that appears packet-loss correlated → current sync-strict reconciler assumes a perfect network; absolute-position wire + client-trust naturally absorbs single-packet drops.

  **Anti-cheat (Phase B in the seed) is OUT OF SCOPE.** Server-side legality check + fall reset is deferred to backlog (candidate Phase 06.8) — to be triggered only when cheating becomes observable. Pre-launch staging is a trusted operator pool at 50 CCU target, so the abuse window is acceptable.

  </domain>

  <decisions>
  ## Implementation Decisions

  ### Scope

  - **D-01:** Phase A only — flip self-player to client-trust + reconciler no-op. Phase B (fall reset + legality check) → backlog (candidate Phase 06.8).
  - **D-02:** Motivating bugs explicitly in scope: (a) ~10 px diagonal-stop drift on staging; (b) dropped-packet hitching tolerance. Current model assumes perfect network — fix that.
  - **D-03:** Inter-player collision arbitration is **out of scope** (not currently observed; theoretical risk only).
  - **D-04:** D-57b and D-58b anti-revert regression tests are **explicitly retired** as an acceptance criterion of this phase. They guard reconciler behavior that is being removed for the self-player. They are replaced by a regression test asserting **the self-player sprite position is never written by `ReconcileEngine.onServerSnapshot`** (positive guard against future revert).

  ### Wire shape + cadence

  - **D-05:** Position-update wire shape (client → server, every tick): `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}`. **Absolute position** (not deltas) — single dropped packet is recovered cleanly by the next.
  - **D-06:** Cadence = **30 Hz steady**, regardless of motion. Idle player sends `{x, y, vx=0, vy=0, facing, anim_state=STAND_*, ...}` 30 times/sec. Matches Minecraft's `PlayerPosition` cadence model and keeps the server's authoritative store always-fresh for remote-player broadcasts.
  - **D-07:** **Int16 quantization** on `x, y` — client snap-rounds to integer pixels before sending. Matches the BNO snap-round model already in `packages/game-logic/src/step.ts:117-122`. Removes float-comparison footguns. Halves packet size vs float.
  - **D-08:** `anim_state` is a 1-byte enum carried in **every position_update** (NOT derivable from kinematics — moving platforms, conveyor platforms, and hexport all produce velocity-without-running states; future spectator/watching pose too). Enum covers 8 directions × {stand, run} + reserved slots for special poses. The existing `SpriteStateMachine` in `packages/game-logic/src/sprite-state-machine.ts` continues to drive client-side animation selection; its current output is what gets shipped.
  - **D-09:** Special non-derivable sprite states (HexportIn / HexportOut / TeleIn / TeleOut / ncol overlays / jokershell / watching) ship via a separate **`set_sprite_override`** event-only intent. Rare event triggers — no place in the 30 Hz hot path. Mirrors the `set_facing` precedent shipped in 06.4 round-3 (D-58c).
  - **D-10:** `facing` field stays in position_update (NOT folded into `anim_state`). Facing = "where the player is pointing" intent (D-58c set_facing semantics); `anim_state` = "what pose to render right now". Different semantics, both needed.

  ### Anti-cheat

  - **D-11:** **Zero server-side anti-cheat in 06.7.** Server trusts the client-reported position fully. Rationale: 50 CCU target, pre-launch private staging, trusted operator pool. Speed-cap + walkable-grid sanity + fall reset → all deferred to future 06.8 (or whenever cheating becomes observable on real-user traffic).

  ### Migration + doctrine

  - **D-12:** **Hard cut on staging.** No feature flag. Staging is the only deployment target pre-launch; operator UAT validates the new model in place. If staging regresses, rollback = `/gsd-undo` on phase commits.
  - **D-13:** **CLAUDE.md Hard Rule 1 — narrow movement carve-out.** Add one paragraph: "Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE — server stores client-reported state. Non-movement state (chat origin, inventory, room transitions, persistence) remains server-authoritative." Drop scores/combat from the example list — REBNO has no plans for those. Restructuring Rule 1 into a tabular trust matrix is **out of scope** (broader rewrite for a future phase if doctrine grows more complex).
  - **D-14:** **Rollback strategy = `/gsd-undo` phase commits.** No dead-code branches kept around.

  ### Research directive (for /gsd-plan-phase 06.7)

  - **D-15:** **Minecraft Java Edition is the canonical reference model.** Researcher (gsd-phase-researcher) must produce a comparison table in RESEARCH.md scoring at minimum:
    - **Minecraft Java Edition** (`PlayerPosition` packet, server-side `Player#move` validation, force-teleport correction)
    - **Quake / Tribes lineage** (client-prediction + server-reconcile — what REBNO is moving AWAY from)
    - **Current REBNO model** (hybrid prediction + 22 px reconciler snap/lerp)
    
    ...against the constraints: 50 CCU target, predictable-movement (core REBNO value), dropped-packet tolerance, single Fly.io machine, Colyseus 0.17 transport.

  - **D-16:** Dropped-packet tolerance is a first-class research dimension. Researcher should investigate: how often is a missed `position_update` survivable for the remote-player path? Do we need server-side extrapolation between packets, or does 30 Hz + absolute-position + Colyseus transport reliability make it irrelevant?

  ### Claude's Discretion

  - Exact `anim_state` enum members + numeric byte values — pick during planning based on existing `SpriteStateMachine` states. Add a binary-format note to `packages/protocol` matching int16 quantization.
  - Whether to keep the `seq` echo / `last_input_seq` round-trip on the position_update path or drop it (current `cInputSchema` uses seq for ordering — likely keep for monotonic ordering + idempotent retries).
  - Server-side `step()` retention strategy — server's authoritative step still drives platforms + future Phase B fall checks. Whether to keep it running on a separate tick for self-player platform-effects (conveyor application) vs. treating client's reported velocity as the truth.
  - Whether to send remote-player broadcast diffs at full 30 Hz or downsampled (server-side fan-out optimization — researcher input).

  ### Folded Todos
  None — the 2026-05-16 split-playwright-smoke todo (score 0.9) keyword-matched on "staging/uat/operator" but is a CI workflow refactor, not a network-model concern. Reviewed-only; see Deferred Ideas.

  </decisions>

  <canonical_refs>
  ## Canonical References

  **Downstream agents MUST read these before planning or implementing.**

  ### Network-model precedent (external — researcher to fetch via WebSearch / WebFetch)
  - **Minecraft Java Edition `PlayerPosition` / `PlayerPositionAndLook` packet semantics** — official protocol docs (wiki.vg or equivalent). The canonical implementation pattern of client-trust + server force-correct. Researcher to produce a comparison table per D-15.
  - **Minecraft `Player#move` server-side validation** — `maxMoveDistanceSquared`, terrain check, force-teleport response. Reference even though REBNO Phase B is deferred — informs the future 06.8 plan.

  ### Current REBNO model (read these to understand what is being changed)
  - `apps/client/src/prediction/reconciler.ts` — `ReconcileEngine.onServerSnapshot` (the 22 px threshold + lerp/snap logic that becomes a no-op for self-player). 123 LOC.
  - `apps/client/src/prediction/predictor.ts` — `PredictionEngine` (self-player local prediction + ack/replay; need to determine whether this stays for input-driven local responsiveness even with client-trust).
  - `apps/client/src/prediction/input-dispatcher.ts` — current `c2s.input` axes wire path (D-09 keydown/keyup events + D-58c `set_facing` precedent for client-authoritative state).
  - `apps/server/src/RebnoRoom.ts` §621-622 — server-side spawn position assignment + tick loop (1389 LOC; pay attention to player.x / player.y writes — those become assignments from the position_update message, not from `step()` output).
  - `apps/server/src/onMessageHandlers.ts` — zod-validated wire intent handlers. New `position_update` + `set_sprite_override` validators belong here.
  - `packages/game-logic/src/step.ts` — pure deterministic step. Becomes server-side platform-effects-only for self-player; remote-player broadcast pass-through. Animation derivation **NOT possible** from x/y/vx/vy per D-08.
  - `packages/game-logic/src/sprite-state-machine.ts` — SpriteStateMachine that produces `anim_state`; ship its current output verbatim on the wire.
  - `packages/game-logic/src/collision.ts` — collision primitives (still needed for client-side prediction-of-self for input responsiveness; server walkable-grid check is deferred to 06.8).
  - `packages/protocol/src/intents.ts` — `cInputSchema` (the precedent shape for the new `position_update` schema).

  ### Project doctrine to update
  - `CLAUDE.md` §"Hard Rules" Rule 1 — narrow movement carve-out per D-13. **This update is part of the 06.7 deliverable**, not a side effect.
  - `.planning/REQUIREMENTS.md` — REQ-SRV-03 / REQ-SRV-14 / REQ-CLI-04 / REQ-CLI-08 references (no requirement change needed — the trust-model swap is a refactor of how those reqs are satisfied, not a redefinition).

  ### Prior-phase context to carry forward
  - `.planning/phases/06.4-cycle-5-gap-closure-d-51b-d-55b-d-55c-d-57b-d-58b-d-60-d-61-/` — D-58c set_facing precedent (client-authoritative wire intent for a state field previously derived server-side). The new `position_update` follows the same playbook.
  - `.planning/phases/06.6-uat-accounts-integer-viewport-scaling-chat-region-clamp-righ/06.6-CONTEXT.md` — prior phase context style + decision-numbering convention.

  ### Phase seed
  - `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-SEED.md` — promoted from the 2026-05-16 todo; full problem statement and Phase A / Phase B framing.

  </canonical_refs>

  <code_context>
  ## Existing Code Insights

  ### Reusable Assets
  - **`SpriteStateMachine` (`packages/game-logic/src/sprite-state-machine.ts`)** — already produces the animation pose enum client-side. Wire its output as `anim_state` on every position_update; no new state machine needed.
  - **`set_facing` intent (06.4 D-58c)** — exact precedent for client-authoritative state field shipped on a wire intent. Copy the validator + handler + broadcast pattern.
  - **`cInputSchema.strict()` zod validator (`packages/protocol/src/intents.ts`)** — the security pattern: zod validates everything on the wire before the server consumes it. Apply identically to `position_update` (sanity-validates int16 range, enum membership, monotonic seq) even though no anti-cheat envelope ships in 06.7.
  - **`HEARTBEAT_INTERVAL_MS = 15_000` in `input-dispatcher.ts`** — heartbeat-keepalive pattern. Position_update at 30 Hz IS the heartbeat — no separate keepalive needed.

  ### Established Patterns
  - **Deterministic `step()` in `packages/game-logic`** — pure function, no I/O, runs identically in browser and Node. Stays useful for client-side input-responsiveness prediction (self-player) and for platform/conveyor effect application. Remote-player position is no longer derived from `step()` — it's stored from the wire.
  - **Threat-model invariant T-06.1.02-01 (step.ts:151-158 comment)** — server never trusts client-fabricated walkable_grid. Compatible with 06.7: server still doesn't consume client grid; it just stores client-reported position. Document the new trust line clearly in CLAUDE.md per D-13.
  - **REQ-SRV-14 deterministic broadcast envelope** — the existing state-diff envelope from Phase 4 stays. `position_update` from a client mutates the authoritative `PlayerState`; broadcast envelope unchanged.
  - **`globalThis.__rebno.lastReconcile*` diagnostic ring buffer** (`reconciler.ts:73-92`) — operator-debugging surface. Remove cleanly during 06.7; if a self-reconciler regression slips in, this is the canary that resurfaces.

  ### Integration Points
  - **`onMessageHandlers.ts`** — add `position_update` handler (validate via zod, write into PlayerState, broadcast in next tick) + `set_sprite_override` handler.
  - **`RebnoRoom.ts` tick loop** — server `step()` no longer computes self-player position from axes; instead it consumes the most-recent client-reported `{x, y, vx, vy, facing, anim_state}` and broadcasts. Platform/conveyor application TBD per Claude's Discretion above.
  - **`apps/client/src/net/colyseus-client.ts`** — replace the axes-only dispatcher path with the position_update dispatcher. Existing `input-dispatcher.ts` keydown/keyup events still drive **local** SpriteStateMachine; the dispatcher swap is "what we send", not "how we read input".
  - **`apps/client/src/prediction/reconciler.ts`** — self-player branch becomes no-op (return early when `snap.account_id === local.account_id`). Remote-player branch unchanged. Document the split clearly in the file header.
  - **`apps/client/src/prediction/predictor.ts`** — likely stays for client-side local input-to-position prediction (instant input responsiveness without round-trip). Researcher to confirm during planning.

  </code_context>

  <specifics>
  ## Specific Ideas

  - **Minecraft Java Edition is the explicit precedent.** User asked specifically how Minecraft implements its semi-server-authoritative movement and confirmed it as a research reference. The pattern (client-trust + server validation as separate concerns) is the structural blueprint for 06.7 (Phase A) + 06.8 (future Phase B).
  - **Absolute position on every packet (not deltas).** User reasoned about packet-loss tolerance from first principles: the current model "effectively assumes a perfect network connection", which it shouldn't. Mirroring Minecraft's absolute-position wire makes single-packet drops invisible.
  - **Animation cannot be derived from kinematics.** User caught and corrected the initial idea of server-deriving `anim_state` from `(x, y, vx, vy)`. Counter-examples: moving platforms (velocity-without-running), conveyor platforms (velocity-without-running), hexport (motion-with-special-pose). Ship `anim_state` on the wire explicitly.
  - **No scores or combat in REBNO scope.** User clarified during Hard Rule 1 discussion. Rule 1 carve-out language must reflect the actual REBNO domain (chat, inventory, room transitions, persistence), not generic MMO doctrine.
  - **Trust-fully on staging.** User explicitly rejected speed-cap-as-cheap-first-line and logging-only options for 06.7. Pre-launch + 50 CCU + private = no anti-cheat envelope justifies the implementation cost yet.

  </specifics>

  <deferred>
  ## Deferred Ideas

  - **Phase B — server illegal-position detection + fall reset.** Tick-by-tick `isPositionLegal(x, y, walkable_grid)` check; if illegal > N ticks (~250-500 ms grace), trigger fall event = forced animation + position reset to last-known-legal-position or designated respawn. Anti-cheat backstop. **Candidate Phase 06.8** — trigger when cheating becomes observable on real-user traffic OR before public launch, whichever comes first. Needs BNO legacy fall-spec extraction (drift falls, void falls, respawn target) as research input.
  - **Inter-player collision arbitration.** Two clients claiming overlapping positions — server picks one canonical (latest-update-wins / lower-account-id / etc.) OR client-side visual nudge offset. Defer until observed in operator UAT.
  - **Speed-cap server-side validation** (`max_delta_per_tick = RUN_SPEED * 1.5`). Cheap first-line cheat detection. Backlog for 06.8 alongside fall reset.
  - **Walkable-grid sanity check server-side.** Server verifies client position is on a walkable tile. Requires server to compute walkable_grid (currently client-only per T-06.1.02-01). Backlog for 06.8.
  - **CLAUDE.md trust-doctrine table restructure.** Replace Rule 1 prose with a per-domain authority matrix. Defer until doctrine grows complex enough to justify the rewrite — Rule 1 narrow carve-out (D-13) is the right move now.
  - **Server-side `step()` future role.** Whether server keeps running `step()` for platform/conveyor effect application (server applies platform vx/vy to player position) vs. trusting the client's reported position to already include platform-carry. Planner + researcher input needed.

  ### Reviewed Todos (not folded)
  - `2026-05-16-split-playwright-smoke-into-3rd-workflow-job.md` (area: ci, score 0.9) — Operator-suggested 06.5 follow-up to split `deploy-staging.yml` `full-path` into separate deploy + smoke jobs so `gh run watch` can distinguish "deploy succeeded but smoke failed" from "deploy failed". **Out of scope for 06.7** — different domain (CI workflow refactor, not network model). Keyword-match was on "staging/uat/operator", not on substance. Stays in `.planning/todos/pending/` for a future CI-polish phase.

  </deferred>

  ---

  *Phase: 06.7-network-model-client-trust-fall-trigger*
  *Context gathered: 2026-05-17*

  ### Research Findings (RESEARCH.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger — Research

  **Researched:** 2026-05-17
  **Domain:** Real-time multiplayer netcode trust model (client-authoritative position with absolute-position state replication)
  **Confidence:** HIGH for stack/architecture; MEDIUM for downsampling and broadcast-cadence recommendations (validated against Minecraft analog, but specific Colyseus 0.17 fan-out telemetry will only come from staging UAT)

  <user_constraints>
  ## User Constraints (from CONTEXT.md)

  ### Locked Decisions

  **Scope**
  - **D-01:** Phase A only — flip self-player to client-trust + reconciler no-op. Phase B (fall reset + legality check) → backlog (candidate Phase 06.8).
  - **D-02:** Motivating bugs explicitly in scope: (a) ~10 px diagonal-stop drift on staging; (b) dropped-packet hitching tolerance. Current model assumes perfect network — fix that.
  - **D-03:** Inter-player collision arbitration is **out of scope** (not currently observed; theoretical risk only).
  - **D-04:** D-57b and D-58b anti-revert regression tests are **explicitly retired** as an acceptance criterion of this phase. They guard reconciler behavior that is being removed for the self-player. Replaced by a regression test asserting **the self-player sprite position is never written by `ReconcileEngine.onServerSnapshot`** (positive guard against future revert).

  **Wire shape + cadence**
  - **D-05:** Position-update wire shape (client → server, every tick): `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}`. **Absolute position** (not deltas) — single dropped packet recovered cleanly by the next.
  - **D-06:** Cadence = **30 Hz steady**, regardless of motion. Idle player sends `{x, y, vx=0, vy=0, facing, anim_state=STAND_*, ...}` 30 times/sec. Matches Minecraft's `PlayerPosition` cadence model and keeps the server's authoritative store always-fresh for remote-player broadcasts.
  - **D-07:** **Int16 quantization** on `x, y` — client snap-rounds to integer pixels before sending. Matches the BNO snap-round model already in `packages/game-logic/src/step.ts:117-122`. Removes float-comparison footguns. Halves packet size vs float.
  - **D-08:** `anim_state` is a 1-byte enum carried in **every position_update** (NOT derivable from kinematics — moving platforms, conveyor platforms, hexport all produce velocity-without-running states). Enum covers 8 directions × {stand, run} + reserved slots. The existing `SpriteStateMachine` in `packages/game-logic/src/sprite-state-machine.ts` continues to drive client-side animation selection; its current output is what gets shipped.
  - **D-09:** Special non-derivable sprite states (HexportIn/Out, TeleIn/Out, ncol overlays, jokershell, watching) ship via a separate **`set_sprite_override`** event-only intent. Mirrors `set_facing` precedent shipped in 06.4 round-3 (D-58c).
  - **D-10:** `facing` stays in position_update (NOT folded into `anim_state`). Facing = "where the player is pointing"; `anim_state` = "what pose to render right now". Different semantics, both needed.

  **Anti-cheat**
  - **D-11:** **Zero server-side anti-cheat in 06.7.** Server trusts client-reported position fully. 50 CCU, pre-launch private staging, trusted operators — speed-cap, walkable-grid sanity, and fall reset all deferred to 06.8.

  **Migration + doctrine**
  - **D-12:** **Hard cut on staging.** No feature flag. Rollback = `/gsd-undo` on phase commits.
  - **D-13:** **CLAUDE.md Hard Rule 1 — narrow movement carve-out.** One paragraph: "Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE — server stores client-reported state. Non-movement state (chat origin, inventory, room transitions, persistence) remains server-authoritative." Drop scores/combat from the example list — REBNO has none.
  - **D-14:** **Rollback strategy = `/gsd-undo` phase commits.** No dead-code branches.

  **Research directive**
  - **D-15:** Researcher must produce a comparison table scoring Minecraft Java Edition vs. Quake/Tribes lineage vs. current REBNO model against constraints: 50 CCU, predictable-movement, dropped-packet tolerance, single Fly.io machine, Colyseus 0.17.
  - **D-16:** Dropped-packet tolerance is a first-class research dimension.

  ### Claude's Discretion
  - Exact `anim_state` enum members + numeric byte values — pick during planning based on existing `SpriteStateMachine` states. Add a binary-format note to `packages/protocol`.
  - Whether to keep `seq`/`last_input_seq` echo on the position_update path or drop it.
  - Server-side `step()` retention — whether to keep it running on a separate tick for self-player platform-effects (conveyor application) vs. trusting client's reported velocity.
  - Whether to send remote-player broadcast diffs at full 30 Hz or downsampled (server fan-out optimization).

  ### Deferred Ideas (OUT OF SCOPE for 06.7)
  - **Phase B — server illegal-position detection + fall reset** (→ candidate 06.8).
  - **Inter-player collision arbitration** (defer until observed).
  - **Speed-cap server-side validation** (→ 06.8).
  - **Walkable-grid sanity check server-side** (→ 06.8).
  - **CLAUDE.md trust-doctrine table restructure** (Rule 1 narrow carve-out only for now).
  - **Server-side `step()` future role** (planner + researcher input — addressed below).
  </user_constraints>

  <phase_requirements>
  ## Phase Requirements

  | ID | Title (from `traceable-reqs.toml`) | Research Support |
  |----|-----------------------------------|------------------|
  | REQ-SRV-03 | `apps/server` runs Node 22 + Colyseus 0.17 with one Room class for the MVP slice | Server-side: new `position_update` + `set_sprite_override` handlers added to `onMessageHandlers.ts`; tick-loop in `RebnoRoom.ts` modified to skip `step()` for self-player movement (covered in "Architecture Patterns" below) |
  | REQ-SRV-14 | Deterministic moving-platform sync via game-logic step; clients interpolate, never extrapolate | Platforms remain server-authoritative — they are world state, NOT player movement. The carve-out is narrow: only player x/y/vx/vy/facing/anim_state flip to client-trust. Platforms stay derived from `step()` (covered in "Pitfalls" + "Architecture Patterns") |
  | REQ-CLI-04 | Client-side prediction + entity interpolation + server reconciliation | Reconciler becomes no-op for self-player; entity interpolation/extrapolation for REMOTE players UNCHANGED (server state IS authoritative for those). The req remains satisfied — its implementation morphs (covered in "Don't Hand-Roll" + "Architecture Patterns") |
  | REQ-CLI-08 | MVP GATE: two players join, move, and chat over deployed server | Two-player movement smoothness regression — the existing 06.4 e2e integration tests against staging confirm REQ-CLI-08 still holds post-flip. Specifically: `apps/client/src/__test__/colyseus-client.test.ts` + server `event-driven-input.integ.test.ts` (covered in "Validation Architecture") |

  **Required stages** for each: `doc` + `impl` mandatory for all; `unit` for REQ-SRV-03/SRV-14/CLI-04; `int` for REQ-SRV-03/SRV-14/CLI-08.
  </phase_requirements>

  ## Summary

  The phase flips player-movement trust from hybrid client-prediction + server-authoritative-reconciler to **client-authoritative absolute-position streaming**, matching Minecraft Java Edition's `Set Player Position` model. Client streams `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}` at 30 Hz; server stores the most-recent payload and broadcasts to remote players. The reconciler becomes a no-op for the self-player; the remote-player extrapolation path is preserved because the server-stored value IS authoritative for those peers.

  Three findings drive the recommendation:

  1. **Minecraft's model is the industry-standard precedent.** Minecraft sends absolute-position `Set Player Position` packets at 20 Hz, server stores them as authoritative state, and uses force-teleport `Synchronize Player Position` only as a correction backstop (the latter being REBNO's deferred 06.8). The "trusted store + corrective override" pattern is exactly Phase A + Phase B.
  2. **30 Hz absolute position over TCP/Colyseus is robust to single-packet loss by construction.** Each packet is fully self-describing; missing tick N's update just means tick N+1's update lands one frame later. No state divergence is possible because there's no delta to lose context for. Server-side extrapolation between packets is unnecessary at 30 Hz with Colyseus's TCP ordering guarantee.
  3. **Custom server-side broadcast fan-out optimization (downsampling) is premature.** At 50 CCU × 30 Hz × ~20 B payload, broadcast bandwidth is ~37 kbps down per client — well within Fly.io shared-cpu-1x. Ship at full 30 Hz; revisit if profiling shows fan-out cost.

  **Primary recommendation:** Implement the wire shape exactly as D-05 specifies, ship at 30 Hz steady, keep `seq` for ordering/idempotent-retries (existing `cInputSchema` precedent), drop the server-side `step()` call for self-player position entirely (it stops being authoritative for x/y), retain `step()` for platforms (REQ-SRV-14 unchanged), broadcast at full 30 Hz patch rate, use a 1-byte packed `anim_state` enum with bits 0-2 = octant + bit 3 = running + bits 4-7 reserved.

  ## Architectural Responsibility Map

  | Capability | Primary Tier | Secondary Tier | Rationale |
  |------------|-------------|----------------|-----------|
  | Self-player position (x, y) | Browser/Client | API/Backend (storage only) | D-05 — client owns its position; server is a write-through store |
  | Self-player velocity (vx, vy) | Browser/Client | API/Backend (storage only) | Derived from intent on the client; server stores for broadcast only |
  | Self-player anim_state | Browser/Client | API/Backend (storage only) | D-08 — non-derivable from kinematics; client SpriteStateMachine output is canonical |
  | Self-player facing | Browser/Client | API/Backend (storage only) | D-10 — D-58c set_facing precedent extended to every-tick streaming |
  | Remote-player rendering | Browser/Client | — | Client consumes server-broadcast state and runs existing extrapolation cap (250 ms per ADR 0007) |
  | Remote-player state-of-record | API/Backend | — | Server is the canonical store of every player's last-reported {x, y, vx, vy, facing, anim_state}; clients receive via Colyseus state-diff |
  | Platform position | API/Backend | — | REQ-SRV-14 unchanged: deterministic, derived from `step()` on the server, NOT carved out |
  | Chat origin / sender identity | API/Backend | — | Hard Rule 1 unchanged — server-tagged from `auth.account_id` per `onMessageHandlers.ts:182-188` |
  | Inventory / persistence | API/Backend | Database/Storage | Hard Rule 1 unchanged |
  | Room transitions | API/Backend | — | Hard Rule 1 unchanged — `room_join` handler is the authority |
  | Spawn position | API/Backend | — | `RebnoRoom.findHomePortal()` assigns; client adopts on join. Only the FIRST authoritative position; after spawn, client owns it |
  | Anti-cheat enforcement | (none in 06.7) | — | Deferred to 06.8 |

  ## Standard Stack

  ### Core (already pinned — no changes)
  | Library | Version | Purpose | Why Standard |
  |---------|---------|---------|--------------|
  | `colyseus` | 0.17.10 | Authoritative-server framework, room/state-diff/WS transport | `[VERIFIED: apps/server/package.json:32]` — already in use; no version change |
  | `@colyseus/sdk` | 0.17.42 | Client-side Colyseus | `[VERIFIED: apps/client/package.json:21]` — already in use |
  | `@colyseus/schema` | 4.0.23 | Schema-delta encoder for the `PlayerState`/`RoomState` MapSchemas | `[VERIFIED: apps/server/package.json:21]` — already in use |
  | `phaser` | 3.90.0 | Client-side renderer | `[VERIFIED: apps/client/package.json:25]` — already in use |
  | `zod` | 3.23.x | Wire validator at the onMessage boundary | `[VERIFIED: apps/server/package.json:45]` — already in use; new `cPositionUpdateSchema` adds here |
  | `msgpackr` | 1.11.10 | S2C event codec | `[VERIFIED: packages/protocol/src/events.ts:7]` — already in use; no new S2C event needed |

  ### Supporting — no new libraries

  The phase introduces **zero new dependencies**. Everything is a refactor of how the existing libraries are wired.

  **Version verification:** Pins above are the **current** versions in `package.json` and were verified against the files in this session. No dependency upgrades are part of the phase.

  ### Alternatives Considered (and rejected per CONTEXT)
  | Instead of | Could Use | Why Rejected |
  |------------|-----------|--------------|
  | Colyseus state-diff (PlayerState mutation) for broadcast | A new msgpackr s2c `position_broadcast` event | The Colyseus state-diff path already broadcasts `PlayerState` mutations at `setPatchRate(TICK_MS)`. Re-using it costs zero new wire shapes. Adding a parallel msgpackr broadcast would create two sources of truth and split the test surface. |
  | Float64 x/y on the wire | Float32 or fixed-point Q16.16 | D-07 locks int16. Float64 is 4× larger than int16; pixel-quantized positions don't need sub-integer precision because step.ts already snap-rounds before write. |
  | Binary packed position_update | msgpackr-encoded object | The existing `cInputSchema` lives as a JSON-shaped object (Colyseus message channel does its own packing). Binary packing would gain ~10-20 B per frame at the cost of a parallel codec test surface. Defer unless 50 CCU CPU profile shows the JSON path as a hot spot. |

  ## Architecture Patterns

  ### System Architecture Diagram

  ```
  Self-player flow (NEW in 06.7):
    Keyboard input  →  InputDispatcher (client)
                         ↓
                       PredictionEngine.predictTick(layout)   [LOCAL FEEL — kept]
                         ↓ (writes localState.x/y/vx/vy)
                       Phaser sprite render               [INSTANT RESPONSE]
                         ↓
                       PositionDispatcher.send(position_update)  [NEW: every 30 Hz tick]
                         │
                         │     wire: {x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}
                         │     binary: int16+int16+int16+int16+u8+u8+u32+u32 = 18 B payload
                         ↓
                [ Colyseus WS / TCP — reliable, ordered ]
                         ↓
                       RebnoRoom.onMessage('position_update')
                         ↓
                       cPositionUpdateSchema.safeParse()  [zod strict — D-04]
                         ↓ (pass) write into PlayerState.{x, y, vx, vy, facing, anim_state}
                         │
                         │
                Tick loop:
                RebnoRoom.setSimulationInterval(TICK_MS=50)
                         ↓
                       step() runs for PLATFORMS ONLY (REQ-SRV-14)
                         │   no longer reads/writes self-player position
                         ↓
                       Colyseus setPatchRate(TICK_MS=50)
                         ↓
                [ broadcast PlayerState diffs to all clients ]
                         ↓
                Remote-player flow (UNCHANGED):
                       PlayerState.onChange (per-field)
                         ↓
                       PlayerRenderer.setRemotePosition (extrapolation cap 250 ms)
                         ↓
                       deriveFrameWithAuthoritativeFacing (06.4 D-58c — kept)
                         ↓
                       remote sprite render

    Self-reconciler path (REMOVED):
                       ReconcileEngine.onServerSnapshot
                         ↓ for self-player: NO-OP (early return when snap.account_id === local.account_id)
                         ↓ for remote-player: NOT INVOKED (remote path uses PlayerRenderer directly, NOT ReconcileEngine)
                         │   → Effectively dead code for self; can be removed entirely once 06.8 confirms no future need
  ```

  Key data-flow change vs. the current model:
  - **Before:** keypress → c2s.input (axes) → server.step() → state diff → reconciler (self & remote)
  - **After:** keypress → c2s.position_update (absolute x/y/vx/vy/facing/anim_state from client predictor) → state diff (passthrough) → renderer (remote only)

  ### Recommended Project Structure

  No structural changes. All file paths exist; this is a refactor:

  ```
  packages/protocol/src/
  ├── intents.ts              # ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema, AnimState enum
  ├── events.ts               # unchanged (force_reset event reserved for 06.8 fall trigger)
  └── version.ts              # BUMP PROTOCOL_VERSION 3 → 4 (wire shape change)

  apps/server/src/
  ├── onMessageHandlers.ts    # ADD: position_update handler + set_sprite_override handler
  └── RebnoRoom.ts            # MODIFY: tick loop — step() no longer writes self-player x/y/vx/vy/facing

  apps/client/src/
  ├── prediction/
  │   ├── predictor.ts        # KEEP — local-feel prediction stays for input responsiveness
  │   ├── reconciler.ts       # MODIFY: no-op for self-player; remote unchanged. Remove globalThis.__rebno.lastReconcile* writes for self
  │   └── input-dispatcher.ts # ADD: per-tick PositionDispatcher branch alongside the keydown/keyup InputDispatcher
  └── net/
      └── colyseus-client.ts  # MODIFY: bindHandlers wires position_update dispatch on local player tick

  CLAUDE.md                   # MODIFY: Hard Rule 1 narrow carve-out per D-13
  ```

  ### Pattern 1: Absolute-position client-trust streaming (Minecraft-canonical)

  **What:** Client computes its position locally and ships absolute coordinates to the server every tick. Server stores the latest payload as authoritative state and broadcasts to remote peers. Server does not compute, validate, or correct positions.

  **When to use:** Pre-launch private games where the trust boundary can include the player; games where predictable feel matters more than anti-cheat; small player counts.

  **Example (server side):**
  ```typescript
  // Source: pattern derived from packages/protocol/src/intents.ts:45-59 + apps/server/src/onMessageHandlers.ts:104-136
  // [CITED: Minecraft "Set Player Position" packet — minecraft.wiki/w/Java_Edition_protocol/Packets]
  export const cPositionUpdateSchema = z.object({
    type: z.literal('position_update'),
    x: z.number().int().min(-32768).max(32767),
    y: z.number().int().min(-32768).max(32767),
    vx: z.number().int().min(-32768).max(32767),
    vy: z.number().int().min(-32768).max(32767),
    facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    anim_state: z.number().int().min(0).max(255),  // packed enum (see below)
    seq: z.number().int().min(0),
    monotonic_at_ms: z.number().nonnegative(),
  }).strict();

  ctx.room.onMessage('position_update', (client, raw) => {
    const auth = ctx.getAuth(client.sessionId);
    if (!auth) return;
    if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;
    const parsed = cPositionUpdateSchema.safeParse(raw);
    if (!parsed.success) return;
    const player = ctx.room.state.players.get(client.sessionId);
    if (!player) return;
    player.x = parsed.data.x;
    player.y = parsed.data.y;
    player.vx = parsed.data.vx;
    player.vy = parsed.data.vy;
    player.facing = parsed.data.facing;
    player.anim_state = parsed.data.anim_state;
    player.last_input_seq = parsed.data.seq;  // keep — see "Pattern 4"
  });
  ```

  ### Pattern 2: Server-side step() retains platform authority (REQ-SRV-14 preserved)

  **What:** The server still runs `step()` every tick for platforms (and any future world state). Players are skipped entirely in the player loop within `step()`.

  **When to use:** Whenever the carve-out is partial — only one entity type (player position) flips to client-trust while everything else stays server-authoritative.

  **Example:**
  ```typescript
  // Source: derived from packages/game-logic/src/step.ts:65-235
  // Modification: skip the player-input-application block; players carry the values written
  // by the position_update handler. Platforms still advance per p.x += p.vx * dt_ms.

  export function step(state, inputs, dt_ms) {
    // 1. Advance platforms (UNCHANGED — REQ-SRV-14)
    const newPlatforms = new Map();
    for (const [id, p] of state.platforms) {
      newPlatforms.set(id, { x: p.x + p.vx * dt_ms, y: p.y + p.vy * dt_ms, vx: p.vx, vy: p.vy, cycle_phase: p.cycle_phase + dt_ms });
    }
    // 2. Players: pass-through. The position_update handler already wrote authoritative values.
    //    REMOVED: input-axis decoding, diagonal normalization, per-axis sub-pixel collision.
    return { rev: state.rev + 1, rng_state: state.rng_state, players: state.players, platforms: newPlatforms, room_layout: state.room_layout };
  }
  ```

  **Important nuance:** the existing `step()` signature is preserved; client-side prediction in `predictor.ts` still calls `step()` with the client's local walkable_grid, so client-side wall-slide collision is preserved. The change is purely server-side: the server no longer derives player movement from input axes — but `step()` still exists and still gets called for platforms.

  ### Pattern 3: Self-reconciler no-op (positive guard)

  **What:** When a server snapshot arrives for the local player, the reconciler returns early. The renderer keeps using the predictor's local state, never written by the snapshot path.

  **When to use:** When the server is no longer the authoritative source for self-player position (the client IS).

  **Example:**
  ```typescript
  // Source: derived from apps/client/src/prediction/reconciler.ts:52
  // Replace onServerSnapshot's self-player branch with an early return:

  onServerSnapshot(snap: ServerSnapshot, isSelfPlayer: boolean): void {
    if (isSelfPlayer) {
      // [impl->REQ-CLI-04] D-04 acceptance criterion: self-player sprite is
      // NEVER written by the snapshot path. Self-player position comes from
      // PredictionEngine.predictTick + InputDispatcher only.
      return;
    }
    // Remote-player path unchanged — but note that remote rendering does NOT
    // currently go through ReconcileEngine at all (it goes through
    // PlayerRenderer.setRemotePosition). So this branch is effectively dead
    // code today; we retain the signature for symmetry and the (unused) call
    // site, but the entire ReconcileEngine class is a candidate for removal
    // after staging UAT confirms no remote-player consumer.
  }
  ```

  ### Pattern 4: Keep `seq` echo on position_update (recommended)

  **What:** Client increments a per-message sequence number on every position_update; server echoes the last-received seq in `PlayerState.last_input_seq`.

  **When to use:** Whenever you want ordering/staleness detection and idempotent retries on reconnect.

  **Why keep it:** Three concrete benefits, all cheap:
  1. **Staleness detection:** Server can ignore an out-of-order position_update (`parsed.data.seq <= player.last_input_seq`). Defends against the rare TCP reorder-after-reconnect case.
  2. **Reconnect coherency:** After SRV-06's 10 s grace reconnect, the client may have local seq=N; sending one position_update advances the server's last_input_seq cleanly.
  3. **Existing infrastructure reuse:** `PlayerState.last_input_seq` is already a `@colyseus/schema` field; no protocol change to keep it.

  **Cost:** 4 bytes per frame (u32). Trivial.

  ### Anti-Patterns to Avoid

  - **Don't recompute position server-side after the position_update arrives.** The whole point of the flip is that the client owns it. Running `step()` then overwriting with the client value would just be wasted CPU AND introduce a race window where the rendered/broadcast value flickers between the two.
  - **Don't extrapolate remote players on the server.** Colyseus state-diff fans out per `setPatchRate`; the existing client-side extrapolator (apps/client extrapolation cap 250 ms per ADR 0007) already handles brief gaps. Server extrapolation duplicates this logic on the wrong side of the boundary.
  - **Don't drop the heartbeat semantics.** The existing 15 s `HEARTBEAT_INTERVAL_MS` in `input-dispatcher.ts` survives reconnect-grace. With position_update at 30 Hz, the heartbeat is implicit (any frame proves liveness) — but keep the explicit `c2s.heartbeat` handler for the case where the player is in a menu / chat-input-focused state and is NOT streaming position_updates (movement is paused per `pauseMovement()`).
  - **Don't ship `anim_state` as a string.** D-08 calls for 1-byte enum; sending `"NaviRunDR_003"` per frame at 30 Hz is 14+ bytes of bandwidth waste vs. 1 byte for the pose enum + cycle phase derived client-side from velocity.

  ## Don't Hand-Roll

  | Problem | Don't Build | Use Instead | Why |
  |---------|-------------|-------------|-----|
  | Position broadcasting | A new msgpackr `s2c.position_broadcast` event | The existing Colyseus `PlayerState` MapSchema delta channel | Already pinned at `setPatchRate(TICK_MS)`; already test-covered; adding a parallel msgpackr broadcast doubles the test surface and creates two sources of truth |
  | Position ordering | A custom sequence-window state machine | Existing `last_input_seq` field on `PlayerState` + the seq from D-05 | The seq field exists, is wire-tested, and the comparator is one line: `if (seq <= player.last_input_seq) return;` |
  | Self-player visual smoothing | A new "client-side smoothing buffer" between predict and render | Existing `PredictionEngine.predictTick` + Phaser sprite.setPosition each tick | Local feel is already instant; the smoothing buffer of yesteryear is an artifact of round-trip latency, which the client-trust model eliminates |
  | Server-side anti-cheat envelope | A speed-cap, walkable-grid, illegal-position machine | NOTHING in 06.7 | D-11 LOCKS this. Backlog for 06.8 |
  | Remote-player extrapolation | A new extrapolation algorithm | Existing `PlayerRenderer.setRemotePosition` + extrapolation cap per ADR 0007 + `axis_x_held`/`axis_y_held` on PlayerState | Already shipped; the new `vx`/`vy` fields from position_update populate the same `PlayerState.vx`/`vy` the renderer already consumes |
  | Packet-loss handling | A custom retransmit / sequence-gap detector | Nothing — Colyseus runs over TCP/WS which is reliable + ordered | TCP guarantees delivery and order. The only "loss" possible is the connection drop, which is already handled by SRV-06's 10 s grace |

  **Key insight:** Every component the phase needs already exists; the work is rewiring trust direction, not building new mechanisms.

  ## Comparison Table (D-15)

  Scored against constraints: **50 CCU**, **predictable-movement** (core REBNO value), **dropped-packet tolerance**, **single Fly.io machine**, **Colyseus 0.17 transport**.

  Scale: ✅ aligns / ⚠️ partial / ❌ misaligned.

  | Constraint | Minecraft Java Edition | Quake / Tribes lineage | Current REBNO (pre-06.7) | REBNO post-06.7 (recommended) |
  |------------|------------------------|------------------------|--------------------------|-------------------------------|
  | **Trust model** | Client-trusted position; server stores; "Synchronize Player Position" corrective backstop `[CITED: minecraft.wiki/w/Java_Edition_protocol/Packets]` | Server-authoritative simulation; client predicts, server reconciles `[CITED: fabiensanglard.net/quake3/network.php]` | Server-authoritative simulation; client predicts, server-reconciler-snaps when >22 px divergence `[VERIFIED: reconciler.ts:21]` | Client-trusted (matches Minecraft model). No fall-trigger correction in 06.7 |
  | **Wire shape** | Absolute position: `{X, Y, Z, on_ground}` + variants for rotation. Doubles for coordinates (3×8=24 B per packet) `[CITED: minecraft.wiki]` | Compact input commands + delta-compressed state from old baseline `[CITED: ra.is/unlagged/network.html]` | Axes-only `{seq, axes:{x,y}, buttons_down/up, monotonic_at_ms}` — event-driven, NOT every-tick `[VERIFIED: cInputSchema in intents.ts]` | Absolute position int16: 18 B payload (2+2+2+2+1+1+4+4) |
  | **Cadence** | 20 Hz (server tick rate); client sends position every tick under normal play `[CITED: minecraft.wiki/w/Tick — "20 ticks per second"]` | ~20-30 Hz client snapshots; server snapshots variable | Event-driven on keydown/keyup transitions + 15 s heartbeat — NO per-tick send `[VERIFIED: input-dispatcher.ts:26]` | **30 Hz steady, regardless of motion** (D-06) — matches BNO native tick rate; the heartbeat IS implicit |
  | **Dropped-packet tolerance** | ✅ Excellent — every packet is self-describing; missed packet recovered by next | ⚠️ Quake3 delta-compresses against arbitrary old snapshot; loss recovery is per-snapshot ack `[CITED: fabiensanglard.net/quake3/network.php]` | ❌ Poor — the held-axes map persists state across ticks, but a dropped keyup leaves player walking until heartbeat re-affirms (15 s). Operator UAT 2026-05-16 confirmed "hitching" `[VERIFIED: SEED §Problem]` | ✅ Excellent — absolute-position-per-tick means single packet loss is one missed frame at 30 Hz = 33 ms — invisible. TCP order + Colyseus reliability means packet loss is the connection drop, which SRV-06 grace already handles |
  | **Predictable movement** | ✅ Client owns its position — no rubber-banding under normal play | ⚠️ Reconciler snap if prediction diverges enough; visible in fast-paced FPS | ❌ Failing today — reconciler snap on diagonal-stop produces ~10 px shift `[VERIFIED: SEED §Problem]` | ✅ No reconciler-snap path for self-player; visual position = predictor output, which never reverts |
  | **50 CCU bandwidth** | N/A directly comparable (different scale per server) | Per-server typical 32 CCU; bandwidth scales linearly | Low — event-driven keeps c2s sparse | ~37 kbps down per client (50 peers × 30 Hz × ~25 B per peer); ~15 Mbps aggregate at 50×50. Fly.io shared-cpu-1x handles trivially |
  | **Single Fly.io machine** | ✅ N/A — Minecraft uses one process per server | ✅ Quake3 uses one machine | ✅ Already deployed | ✅ No change — broadcast is still one fan-out per `setPatchRate` tick |
  | **Colyseus 0.17 transport** | N/A (Minecraft is TCP + custom protocol) | N/A (Quake3 is UDP) | ✅ Already configured `[VERIFIED: RebnoRoom.ts:177-178]` | ✅ Same channel (`onMessage('position_update')`) + same state-diff broadcast — zero transport changes |
  | **Anti-cheat posture** | Strong — server force-corrects via `Synchronize Player Position` clientbound | Strong — full simulation authority | Strong — full server authority over x/y | **Zero (D-11)** — accepted risk on pre-launch trusted operator pool |
  | **Implementation cost** | Reference model only — not directly implementable | Heavyweight — full simulation symmetry required | Already shipped | Modest refactor — new handler + handler-side validator + reconciler-no-op + tick-loop trim |

  **Score summary:**
  - **Minecraft model** is the closest aligned reference for REBNO's pre-launch posture: client owns position, server stores it. ✅ for everything that matters.
  - **Quake lineage** is what REBNO is moving AWAY from — it's overkill for a 50 CCU walking-chat game and the reconciler snap is the SOURCE of operator-reported pain.
  - **Current REBNO** scores ❌ on predictable-movement and packet-loss tolerance — both motivating bugs in SEED.

  ## Dropped-Packet Tolerance Analysis (D-16)

  **Question:** how often is a missed `position_update` survivable for the remote-player path? Do we need server-side extrapolation between packets?

  **Finding (HIGH confidence):** No server-side extrapolation needed. Three converging reasons:

  ### 1. Colyseus runs over TCP — there is no "missed packet" in the UDP sense

  `[VERIFIED: WebSearch — Colyseus FAQ + docs.colyseus.io/server/transport/ws]` Colyseus's default transport is WebSocket (TCP). TCP guarantees:
  - **In-order delivery** — frames arrive in send order
  - **Reliable delivery** — frames are retransmitted on loss at the transport layer
  - **No reordering** — application sees a strictly ordered stream

  The only "packet loss" possible from the application's perspective is the connection itself dropping, which already triggers Colyseus's `onLeave` → SRV-06 10 s reconnect grace. During that grace window the player's position is frozen at last-known; this is the correct behavior and matches operator UAT expectations (a dropped player visually pauses, doesn't extrapolate into walls).

  ### 2. 30 Hz absolute position is self-healing by construction

  `[VERIFIED: REQ-SRV-05 in REQUIREMENTS.md + accumulator.ts:8]` Each position_update is fully self-describing — no deltas, no baseline references, no acks required. If frame N is somehow delayed (TCP retransmit blip), frame N+1 lands 33 ms later (at 30 Hz) and contains the same data type — the latest absolute position. The visual effect on the remote viewer: one frame's pause, then jump to current. At 33 ms intervals this is invisible to human perception.

  Compare with Quake 3's delta-compressed snapshots `[CITED: ra.is/unlagged/network.html]`, which can stall ALL subsequent decodes until the baseline arrives — REBNO's model is more robust here.

  ### 3. The remote-extrapolator already exists for the 30 ms gap case

  `[VERIFIED: packages/protocol/src/state.ts:31-32 + ADR 0007 cited there]` `PlayerState.axis_x_held` and `axis_y_held` are broadcast every tick today. The client-side extrapolator uses these to smooth between snapshots (250 ms cap per ADR 0007). With position_update at 30 Hz, gap between consumed snapshots is normally 33 ms; the extrapolator only kicks in when a snapshot is genuinely delayed. The 250 ms cap means even a worst-case TCP-retransmit-spike (~150 ms) is bounded.

  **The new position_update fields populate the same PlayerState slots.** `vx` and `vy` from D-05 land in `PlayerState.vx`/`vy` and the existing extrapolator can incorporate them directly. No new client logic required.

  ### Recommendation
  - **Broadcast cadence:** `setPatchRate(TICK_MS=50)` (current value). At 30 Hz client send + 20 Hz server broadcast, every client snapshot reflects ≤ 50 ms-old data. Acceptable.
  - **No downsampling.** Concrete bandwidth budget: 50 CCU × 30 Hz × ~25 B msgpackr-encoded PlayerState diff per peer = ~37 kbps down per client; aggregate ~15 Mbps over WS. Fly.io shared-cpu-1x is rated 1000s of Mbps NIC throughput — not a constraint.
  - **Defer optimization** until a Phase 7 60 CCU+ stress test demands it. Premature.

  ## Recommended `position_update` Wire Schema

  ### TypeScript shape (zod, JSON over Colyseus channel)

  ```typescript
  // packages/protocol/src/intents.ts — ADD after cSetFacingSchema
  export const cPositionUpdateSchema = z.object({
    type: z.literal('position_update'),
    // Int16 quantized — D-07. Range [-32768, 32767] safely covers BNCentral
    // 8000×6400 px and any room ≤ ~30000 px. Snap-rounded by client (Math.round).
    x: z.number().int().min(-32768).max(32767),
    y: z.number().int().min(-32768).max(32767),
    // BNO instant-set/instant-stop model means vx/vy are typically -5..+5 px/tick.
    // Int16 range trivially holds this with headroom for any future faster items.
    vx: z.number().int().min(-32768).max(32767),
    vy: z.number().int().min(-32768).max(32767),
    // Facing — D-10. Same enum as cSetFacingSchema (legacy compat).
    facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    // anim_state — D-08. Packed 1-byte enum (see byte layout below).
    anim_state: z.number().int().min(0).max(255),
    // seq — keep per Pattern 4. Per-account, monotonic.
    seq: z.number().int().min(0),
    // monotonic_at_ms — 06.4 D-58c precedent; staleness detection without trusting wall-clock.
    monotonic_at_ms: z.number().nonnegative(),
  }).strict();
  ```

  ### Byte layout (binary packing — if Phase 7 demands further compression)

  | Field | Type | Size | Range | Notes |
  |-------|------|------|-------|-------|
  | `x` | int16 LE | 2 B | -32 768..32 767 | Pixel-quantized; client `Math.round()` before send |
  | `y` | int16 LE | 2 B | -32 768..32 767 | Same |
  | `vx` | int16 LE | 2 B | -32 768..32 767 | Typically -5..+5 (RUN_SPEED) |
  | `vy` | int16 LE | 2 B | -32 768..32 767 | Same |
  | `facing` | u8 | 1 B | 0..7 | Octant index (see enum below) |
  | `anim_state` | u8 | 1 B | 0..255 | Packed (see below) |
  | `seq` | u32 LE | 4 B | 0..4 294 967 295 | Per-account monotonic |
  | `monotonic_at_ms` | u32 LE | 4 B | 0..~50 days | Wraps roughly every 50 days — acceptable for a 06.4 D-58c precedent value |
  | **Total payload** | | **18 B** | | |

  **Wire-frame overhead:** Colyseus message-channel framing adds ~10-20 B (channel name "position_update" + msgpackr keyed-object header). End-to-end frame size ~30-40 B. At 30 Hz × 50 CCU upstream that's ~50-60 kbps per server NIC for c2s; trivial.

  ### facing enum (octant index)

  ```typescript
  export const FACING_OCTANT: Record<Direction, number> = {
    R:  0,  // 0°
    UR: 1,  // 45°
    U:  2,  // 90°
    UL: 3,  // 135°
    L:  4,  // 180°
    DL: 5,  // 225°
    D:  6,  // 270°  ← BNO spawn default
    DR: 7,  // 315°
  };
  ```

  ### anim_state byte layout (packed 1-byte enum)

  ```
  bit 7  6  5  4  3  2  1  0
         |reserved | |R| |octant|
                    │   └── 3 bits: facing octant 0..7 (mirrors `facing` field — see note)
                    └── bit 3: 1 = running, 0 = standing
         └── bits 4-7: RESERVED for future poses (jump=0x10, hit=0x20, etc.)
  ```

  Mapped values for the 16 base poses:

  | Pose | Byte | Description |
  |------|------|-------------|
  | `STAND_D`  | `0x06` | facing=D (6), running=0 |
  | `STAND_DR` | `0x07` | facing=DR (7), running=0 |
  | `STAND_R`  | `0x00` | facing=R (0), running=0 |
  | `STAND_UR` | `0x01` | facing=UR (1), running=0 |
  | `STAND_U`  | `0x02` | facing=U (2), running=0 |
  | `STAND_UL` | `0x03` | facing=UL (3), running=0 |
  | `STAND_L`  | `0x04` | facing=L (4), running=0 |
  | `STAND_DL` | `0x05` | facing=DL (5), running=0 |
  | `RUN_D`    | `0x0E` | facing=D (6), running=1 (0x08) |
  | `RUN_DR`   | `0x0F` | facing=DR (7), running=1 |
  | `RUN_R`    | `0x08` | facing=R (0), running=1 |
  | `RUN_UR`   | `0x09` | facing=UR (1), running=1 |
  | `RUN_U`    | `0x0A` | facing=U (2), running=1 |
  | `RUN_UL`   | `0x0B` | facing=UL (3), running=1 |
  | `RUN_L`    | `0x0C` | facing=L (4), running=1 |
  | `RUN_DL`   | `0x0D` | facing=DL (5), running=1 |

  **Note on the facing-vs-anim_state redundancy:** `facing` (top-level field) and `anim_state` bits 0-2 carry the same octant on the wire — this looks redundant. It is intentional per D-10: `facing` = "where the player is pointing" (intent / D-58c set_facing semantics); `anim_state` = "what pose to RENDER right now". The two diverge during HexportIn (`set_sprite_override`) where `facing` may stay at D but rendered pose is the override. Cost of the duplication: 1 byte. Keep both.

  **Reserved bits 4-7** future-proof for jump (`0x10`), hit/stun (`0x20`), watching (`0x30` = set via `set_sprite_override` but mirrored here for state-diff completeness). Up to 16 future poses with zero protocol change.

  ### `set_sprite_override` schema (rare event)

  ```typescript
  // packages/protocol/src/intents.ts — ADD after cPositionUpdateSchema
  export const cSetSpriteOverrideSchema = z.object({
    type: z.literal('set_sprite_override'),
    sprite_id: z.enum([
      'hexport_in', 'hexport_out',
      'tele_in', 'tele_out',
      'ncol_1', 'ncol_2', 'ncol_3', 'ncol_4',
      'jokershell',
      'watching',
      'none',  // clear override
    ]),
    // No coords/velocity — the override is layered atop the position_update stream.
  }).strict();
  ```

  Override semantics (server side): write `PlayerState.sprite_override: string` (new field — additive). Client renderer prefers `sprite_override` over `anim_state` when non-`'none'`. Override is cleared either by the client sending `set_sprite_override(none)` or — once 06.8 lands — by the fall-reset path.

  **This phase ships `set_sprite_override` as a stub** — handler + schema + state field, but no consumer wiring in the renderer (since the listed special poses are all post-MVP). The wiring is forward-compat: when PAR-* phases add Hexport / TeleIn animations, the protocol surface already exists.

  ## Treatment of `seq`, `last_input_seq`, server-side `step()`, broadcast cadence

  ### `seq` / `last_input_seq` — KEEP

  **Recommendation:** Keep both fields exactly as Pattern 4 describes. They are already in the protocol, already on `PlayerState`, and cost 4 B/frame on the wire — trivial for the staleness-detection and reconnect-coherency benefits.

  **Concrete server-side use:**
  ```typescript
  if (parsed.data.seq <= player.last_input_seq) {
    // Out-of-order position_update — discard. Rare under TCP but cheap to defend.
    return;
  }
  player.last_input_seq = parsed.data.seq;
  ```

  ### Server-side `step()` for self-player platform effects — DROP from self-player path

  **Recommendation:** Stop running `step()` for self-player position derivation entirely. Run `step()` ONLY for platforms (REQ-SRV-14) and any future world simulation.

  **Rationale:** Once the client is the authoritative source for `{x, y, vx, vy, facing, anim_state}`, the server has no business running collision/platform-carry logic against the position the client just declared. If the client is on a conveyor platform, the client's `predictor.ts` already runs `step()` locally with the platform state in its WorldState (it reads platforms from the broadcast `PlatformState`). The position the client sends to the server already includes the platform-carry displacement; the server applying it again would double-count.

  **The clean architecture:**
  - Client `predictor.ts` runs `step()` locally with full WorldState (platforms + self + collision grid).
  - Client sends `{x, y, vx, vy, ...}` reflecting the post-step values.
  - Server stores them. Done.
  - Server's own `step()` advances platforms (so the broadcast PlatformState stays fresh) but does NOT touch player position.

  This is the simplest, fewest-moving-parts design and matches Minecraft `[CITED: minecraft.wiki/w/Tick — server tick advances world state, client sends Set Player Position]`.

  **Forward-compat concern (06.8 Phase B):** when the fall trigger lands, the server WILL need a server-side walkable check — but that's a *read-only* check against the client-reported position, NOT a re-derivation. Adding it is orthogonal to running step() for the player.

  ### Broadcast cadence — FULL 30 Hz (= current setPatchRate)

  **Recommendation:** No change. `setPatchRate(TICK_MS=50)` stays — that's 20 Hz broadcast against a 30 Hz client-send. Server stores 30 Hz of updates but only fans out the latest at 20 Hz. Bandwidth-per-client at 50 CCU stays ~37 kbps. Defer downsampling until profiling shows fan-out is a bottleneck.

  Actually — re-examine: the current `setPatchRate(TICK_MS=50)` aligns broadcast with the server tick. With the new model the server tick still runs at 20 Hz (accumulator.ts) for platforms. Keeping `setPatchRate(TICK_MS=50)` is the right call; it aligns broadcast with the server's own simulation cadence and the existing extrapolator's 250 ms cap.

  **Why not match patch rate to the client send rate (30 Hz)?** Two reasons: (1) the server isn't computing player position anymore, so faster broadcast adds no per-tick fidelity; (2) state-diff batching at 50 ms naturally coalesces multiple 33 ms client sends into one network frame downstream, saving bandwidth. Win-win.

  ## Common Pitfalls

  ### Pitfall 1: Reconciler-snap regression

  **What goes wrong:** A future tweak to `reconciler.ts` accidentally re-enables the self-player write path (e.g., someone refactors and removes the early return).

  **Why it happens:** The current self-reconciler is the source of the diagonal-stop drift bug; the temptation to "just fix the threshold" rather than fully no-op is real.

  **How to avoid:** Add an explicit unit test asserting `ReconcileEngine.onServerSnapshot(snap, isSelfPlayer=true)` produces **zero** calls to `sprite.setPosition` and **zero** calls to `sprite.tweenTo`. D-04 calls this out as an acceptance criterion. Pair with a comment in `reconciler.ts` header explaining the invariant.

  **Warning signs:** Operator UAT reports "10 px diagonal shift", "rubber-banding on stop", or "visible jolt on connection blip". Diagnostic ring-buffer `globalThis.__rebno.lastReconcile*` (still useful for debugging remote-path reconciler if retained) will show `lastReconcileReason: 'divergence-threshold'` with self account_id — instant red flag.

  ### Pitfall 2: Stale local prediction after force-resume

  **What goes wrong:** Client reconnects after SRV-06's 10 s grace; server has the LAST-broadcast position (the one BEFORE the disconnect). Client's local predictor has been running the whole time and is now ahead. First position_update post-reconnect tells the server the new (correct) position.

  **Why it's actually fine:** This is the model working as designed. Client is the authoritative source; server adopts client's value on the next position_update. The only visible effect is a single frame where remote viewers see the player at the pre-disconnect position before the post-reconnect update lands — invisible at 30 Hz.

  **How to avoid worry:** Test it explicitly. Server `reconnect.integ.test.ts` should assert: disconnect → reconnect → first position_update overwrites server's stored x/y. (Likely a tiny addition to an existing test.)

  ### Pitfall 3: Position_update spam from a runaway client

  **What goes wrong:** Bug in client send-loop causes 1000+ position_updates per second; server token-bucket drops most but processing each parse still costs CPU.

  **Why it happens:** Client tick loops can degenerate; an infinite loop in render code could starve the tick scheduler and then catch up by firing many in a row.

  **How to avoid:** Reuse the existing token bucket. Add `'position_update'` as a new msg_type to `TokenBucket` per CONTEXT D-22 + Plan 04-08. Suggested budget: refill at 35 tokens/sec, burst capacity 60 (gives 30 Hz + 5/sec headroom for reconnect retransmits, mutes at 60). The cheap-path-before-zod pattern from `onMessageHandlers.ts:69-96` is the right design.

  **Warning signs:** `rate_limit_dropped` log entries spike for `msg_type: 'position_update'`. Operator dashboard should plot this.

  ### Pitfall 4: Forgetting to bump PROTOCOL_VERSION

  **What goes wrong:** Old v3 clients send axes-shape `c2s.input` payloads; new v4 server only handles `position_update`. Mixed-version traffic silently degrades (movement becomes a no-op for the v3 client because nothing writes its position anymore).

  **Why it happens:** The version-bump ritual is documented in `packages/protocol/src/version.ts` history block; easy to skip in the rush of refactoring.

  **How to avoid:** Bump `PROTOCOL_VERSION = 4` and update the history block. The existing `validateAuthFrame` in `intents.ts:285-299` will reject v3 clients with `PROTOCOL_VERSION_MISMATCH` at handshake — exactly the right behavior under D-12 hard-cut deploy. Phase-plan task should call this out explicitly with the deploy-ritual checklist.

  **Warning signs:** Mixed-version is impossible by construction once the server rejects v3; the only way this bug manifests is if the version bump is forgotten. Test: `apps/server/test/protocol-v2-handshake.integ.test.ts` precedent — add a `protocol-v4-handshake` variant.

  ### Pitfall 5: PlayerState schema extension breaks Colyseus delta-encoder

  **What goes wrong:** Adding `anim_state` and `sprite_override` fields to `PlayerState` in `packages/protocol/src/state.ts` requires Colyseus schema-add discipline. `[VERIFIED: state.ts:11-13 comment block]` explicitly warns: "Adding fields to a `@colyseus/schema` class is wire-incompatible with v1 clients per RESEARCH §A5 — the deploy ritual is non-negotiable."

  **Why it happens:** Easy to add a field, forget to bump version, mismatched-schema clients get garbled state.

  **How to avoid:** Same deploy ritual as the PROTOCOL_VERSION bump. The version-history comment in `version.ts` notes "ADR 0007 locks the contract" — read ADR 0007 and ensure entry 4 covers position_update schema. New state.ts fields:
  ```typescript
  @type('number') anim_state: number = 0x06;  // default = STAND_D
  @type('string') sprite_override: string = 'none';
  ```

  ### Pitfall 6: Phaser tween fighting predictor

  **What goes wrong:** Old reconciler called `sprite.tweenTo(x, y, 100)`. If the no-op branch isn't entered correctly, a running tween could still be in-flight at the moment predictor.predictTick writes new sprite.setPosition — visual fight, jerky movement.

  **Why it happens:** Phaser tweens hold a reference to the sprite and apply incrementally; setPosition during a tween creates a race.

  **How to avoid:** When removing the self-reconciler tween path, ALSO ensure GameScene cancels any in-flight tween targeted at the local sprite before the predictor writes. Pattern: `scene.tweens.killTweensOf(localSprite)` in the predictor-write code path. Test surface: `apps/client/src/__test__/game-scene.test.ts`.

  ### Pitfall 7: msgpackr Buffer vs Uint8Array normalization (carry-forward)

  **What goes wrong:** Server emits a position_update derived value via `encodeS2C` (Buffer instance on Node); client expects Uint8Array. The existing `decodeS2C` in `events.ts:80-94` has `normaliseBinary` for `room_layout` — if a new S2C event is added in 06.7 that carries binary, it needs the same normalization.

  **Why it's flagged:** Phase A doesn't add new S2C binary fields (the broadcast goes via Colyseus state-diff, not msgpackr) — but if planning adds an `s2c.position_broadcast` event (rejected option from "Alternatives Considered"), this Pitfall reactivates.

  **How to avoid:** Stick to Colyseus state-diff. If a parallel binary event channel becomes necessary in 06.8, mirror the `normaliseBinary` pattern.

  ### Pitfall 8: Colyseus 0.17 `players.onChange` doesn't fire per-field

  **What goes wrong:** `[VERIFIED: colyseus-client.ts:241-250 comment]` documents this: `players.onChange` only fires on Map key add/remove, NOT on per-field updates. Existing client uses `$(player).onChange(...)` inside `onAdd` to catch per-field deltas. New `anim_state` / `sprite_override` fields fall under the same per-instance handler — no new wiring needed, just verify the existing handler reads them.

  **Warning signs:** Remote avatar plays wrong animation pose despite server logs showing correct `PlayerState.anim_state`. Diagnostic: log `player.anim_state` inside `$(player).onChange` callback in dev builds.

  ## Code Examples

  ### Adding `cPositionUpdateSchema` to the discriminated-union

  ```typescript
  // packages/protocol/src/intents.ts — ADD to c2sSchema
  // Source: pattern from intents.ts:120-127 (existing c2sSchema definition)
  export const c2sSchema = z.discriminatedUnion('type', [
    cAuthSchema,
    cInputSchema,           // KEEP — chat-pause uses pauseMovement → sendInput(0,0); also test harness path
    cChatSendSchema,
    cRoomJoinSchema,
    cHeartbeatSchema,
    cSetFacingSchema,
    cPositionUpdateSchema,   // NEW (06.7)
    cSetSpriteOverrideSchema, // NEW (06.7)
  ]);
  ```

  **Decision call:** keep `cInputSchema`? Yes — for two reasons:
  1. `input-dispatcher.ts:298` `pauseMovement()` sends `{axes: {x:0, y:0}}` to clear server held-input state during chat-input focus. Easier to keep this contract than to remove it.
  2. Test harnesses poke `inputBuffer` directly via `cInputSchema` (per `onMessageHandlers.ts:21-23`). Removing breaks tests.

  **Server-side mechanics:** with the carve-out, the `c2s.input` handler in `onMessageHandlers.ts:104-136` still writes into `heldInputs`, but `tickLoop` no longer reads `heldInputs` for player position. Heldinputs becomes vestigial-but-harmless. Plan-task: leave it for 06.7; remove in a 06.9 cleanup once 06.8 ships.

  ### Position dispatcher (client, every-tick send)

  ```typescript
  // apps/client/src/prediction/position-dispatcher.ts  (NEW FILE)
  // [impl->REQ-CLI-04] [impl->REQ-CLI-08]
  // Source: pattern derived from input-dispatcher.ts:256-286 sendInput
  import type { Room } from '@colyseus/sdk';
  import type { PredictionEngine } from './predictor.js';
  import type { SpriteStateMachine } from '../render/SpriteStateMachine.js';
  import { packAnimState, FACING_OCTANT } from '@rebno/protocol';

  export class PositionDispatcher {
    private nextSeq = 0;
    constructor(
      private readonly room: Pick<Room, 'send'>,
      private readonly prediction: PredictionEngine,
      private readonly getFacing: () => Direction,
      private readonly getIsRunning: () => boolean,
    ) {}

    /** Called once per sim tick (30 Hz) by GameScene.update. */
    sendTick(monotonic_at_ms: number): void {
      const sim = this.prediction.getLocalState();
      const facing = this.getFacing();
      const isRunning = this.getIsRunning();
      this.room.send('position_update', {
        type: 'position_update',
        x: Math.round(sim.x),
        y: Math.round(sim.y),
        vx: Math.round(sim.vx),
        vy: Math.round(sim.vy),
        facing,
        anim_state: packAnimState(facing, isRunning),
        seq: ++this.nextSeq,
        monotonic_at_ms,
      });
    }
  }
  ```

  ### Server tickLoop after the change

  ```typescript
  // apps/server/src/RebnoRoom.ts — MODIFIED tickLoop (compare to existing :1274-1315)
  private tickLoop(realDt: number): void {
    const { newAcc, ticks } = advanceAccumulator(this.accumulator, realDt);
    this.accumulator = newAcc;
    if (realDt > 100 || ticks > 4) {
      log.warn({ event: 'tick_loop_slow', realDt, ticks, roomId: this.roomId, playerCount: this.state.players.size },
        'tick loop slow — event-loop stall suspected');
    }
    for (let i = 0; i < ticks; i++) {
      // Server step() ONLY for platforms (REQ-SRV-14).
      // Player position is no longer derived here — it's written by the
      // position_update handler. Pass an empty inputs map: with the player
      // pass-through change in step.ts (Pattern 2 above), the players map
      // round-trips unchanged.
      const next = step(this.toWorldState(), new Map(), TICK_MS);
      this.applyToColyseusState(next);
    }
  }
  ```

  ## State of the Art

  | Old Approach | Current Approach | When Changed | Impact |
  |--------------|------------------|--------------|--------|
  | Quake3 server-authoritative simulation + reconciler | Minecraft-style client-trusted position + corrective override (the "fall trigger" backstop in 06.8) | Modern small-scale multiplayer (post-~2015) | Predictable feel, simpler server, smaller anti-cheat surface for trusted player pools |
  | Event-driven axes input | Per-tick absolute position streaming | The phase | Single dropped packet recovers naturally; no held-state divergence |
  | Hybrid prediction + threshold-snap reconciler | Pure client-feel for self, server-broadcast for remote | The phase | Removes the diagonal-stop ~10 px drift root cause |

  **Deprecated/outdated (post-06.7):**
  - `apps/client/src/prediction/reconciler.ts` `globalThis.__rebno.lastReconcile*` writes for the self-player — remove cleanly. Operator diagnostic still useful if a remote-snap path is added in 06.8 (it currently isn't called for remotes either, but the symmetry is worth preserving until 06.8 confirms).
  - `DIVERGENCE_THRESHOLD_PX = 22` constant — stays defined but unused. Mark `@deprecated 06.7` in the comment.

  ## Assumptions Log

  | # | Claim | Section | Risk if Wrong |
  |---|-------|---------|---------------|
  | A1 | `[ASSUMED]` Bandwidth at 50 CCU × 30 Hz × 25 B is ~15 Mbps aggregate / ~37 kbps per client | "Standard Stack" + "Dropped-Packet Tolerance" | Low — back-of-envelope is conservative; even if 3× off, still well under Fly.io shared-cpu-1x NIC budget |
  | A2 | `[ASSUMED]` Colyseus state-diff batches multiple 33-ms client sends into one 50-ms broadcast frame downstream | "Broadcast cadence" recommendation | Low — Colyseus docs `[CITED: docs.colyseus.io/state — "only state mutations are broadcasted at every patch"]` confirm property-level batching; the temporal coalescing is implied but not explicitly tested in this session |
  | A3 | `[ASSUMED]` Client `predictor.ts` already incorporates platform-carry into its local step() | "Server-side step() retention" — DROP recommendation | Medium — if `predictor.ts` does NOT consume PlatformState in its local WorldState, then dropping server-side player-position step() leaves the client unable to compute platform-carry locally. **Mitigation:** verify before implementation. Predictor.ts:105-115 builds `prevState.platforms: new Map()` (empty) — so as of today the client does NOT have full platform-carry. This is acceptable for 06.7 (MVP slice has no platforms per CDOC/PAR-03 deferral) but the assumption needs explicit handoff to Phase 7 when platforms ship. Plan-phase should flag this. |
  | A4 | `[ASSUMED]` Token bucket budget of 35 tokens/sec, burst 60 is sufficient for position_update at 30 Hz | "Pitfall 3" | Low — rate-limit values are tunable post-staging; default starting point chosen with 5 Hz headroom |
  | A5 | `[ASSUMED]` PROTOCOL_VERSION bump from 3 → 4 is the right path (vs. additive optional fields) | "Pitfall 4" | Low — wire shape is genuinely changing (new `position_update` handler is the new authoritative source for player position); D-12 hard-cut + version-history convention `[VERIFIED: version.ts:14-22]` makes the bump the established practice |
  | A6 | `[ASSUMED]` Bits 4-7 of `anim_state` reserved for future poses (jump/hit/watching) covers needs through Phase 7 PAR-* | "anim_state byte layout" | Low — 4 reserved bits = 16 future poses; legacy BNO has ~6 special poses tracked in SEED |

  ## Open Questions (RESOLVED)

  1. **A3 — does client-side `predictor.ts` need to start consuming PlatformState?**
     - What we know: predictor.ts:105-115 builds a WorldState with `platforms: new Map()` (empty)
     - What's unclear: when Phase 7 adds platform-bearing rooms, will the position the client SENDS include platform-carry displacement, or will it omit it (causing visual sync to lag platforms)?
     - Recommendation: Plan-phase task should NOT add platform-carry to predictor.ts in 06.7 (MVP has no platforms). Add an explicit cross-reference comment in the predictor + flag this in PAR-03 planning.

  2. **Should `ReconcileEngine` be deleted entirely vs. neutered to a no-op?**
     - What we know: ReconcileEngine.onServerSnapshot is invoked from somewhere in GameScene (likely; need to confirm during planning). The current code already documents (06.4 round-3 comments) that remote players go through PlayerRenderer, not ReconcileEngine.
     - What's unclear: is there ANY caller of ReconcileEngine.onServerSnapshot in active client code post-06.7? If not, the cleanest action is full deletion of the file + test.
     - Recommendation: Planner should grep `apps/client/src` for `ReconcileEngine` and `onServerSnapshot` callers. If zero non-test callers, full deletion is safer than no-op-with-trapdoor. Document the diagnostic-ringbuffer-loss tradeoff.

  3. **Should `c2s.input` and `heldInputs` be removed in 06.7 or deferred to 06.9 cleanup?**
     - What we know: `cInputSchema` + heldInputs are vestigial once position_update lands (server.step() no longer reads heldInputs for player position). `pauseMovement()` chat-focus contract uses cInputSchema.
     - What's unclear: removing immediately requires re-routing the chat-focus contract (probably to a new `c2s.pause_movement` no-op or just dropping it since position_update streaming with vx=vy=0 IS the new "paused" signal).
     - Recommendation: KEEP in 06.7. Cleanup phase (call it 06.9 or fold into 06.8) removes after the new model is operator-validated.

  4. **Should the protocol track a server-broadcast `last_received_at_server_monotonic_ms` for round-trip diagnostics?**
     - What we know: client sends `monotonic_at_ms`; server doesn't echo a corresponding timestamp.
     - What's unclear: would operators benefit from a c2s/server-stamp round-trip diagnostic for staging UAT?
     - Recommendation: defer; no signal from operator that this is needed. Position itself self-validates.

  ## Environment Availability

  Skipped — phase is purely code refactor against existing stack. All dependencies already pinned in `apps/server/package.json` and `apps/client/package.json`; no new tooling required.

  ## Validation Architecture

  ### Test Framework
  | Property | Value |
  |----------|-------|
  | Framework | `vitest` 4.1.5 (server) / 3.2.4 (client) `[VERIFIED: package.json files]` |
  | Config file | Implicit `vitest` config in each app + workspace |
  | Quick run command (server) | `pnpm -C apps/server test` |
  | Quick run command (client) | `pnpm -C apps/client test` |
  | Full suite command | `pnpm -r test` (workspace-recursive) |
  | Integration tests (server) | `pnpm -C apps/server test:integration` |

  ### Phase Requirements → Test Map

  | Req ID | Behavior | Test Type | Automated Command | File Exists? |
  |--------|----------|-----------|-------------------|-------------|
  | REQ-SRV-03 | server `position_update` handler validates with cPositionUpdateSchema (zod strict) and writes to PlayerState | unit | `pnpm -C apps/server test position-update` | Wave 0: NEW `apps/server/test/position-update.test.ts` |
  | REQ-SRV-03 | server `position_update` handler is rate-limited and rejects stale seq | unit | `pnpm -C apps/server test position-update-rate-limit` | Wave 0: NEW (covered in same file) |
  | REQ-SRV-03 | server `set_sprite_override` handler accepts valid sprite_id, writes to PlayerState.sprite_override | unit | `pnpm -C apps/server test sprite-override` | Wave 0: NEW `apps/server/test/sprite-override.test.ts` |
  | REQ-SRV-03 | end-to-end: client sends position_update → server stores → broadcasts → second client sees update | integration | `pnpm -C apps/server test:integration position-update` | Wave 0: NEW `apps/server/test/position-update.integ.test.ts` |
  | REQ-SRV-03 | PROTOCOL_VERSION 3 client rejected at handshake (close 4400) | integration | `pnpm -C apps/server test:integration protocol-v4-handshake` | Wave 0: NEW `apps/server/test/protocol-v4-handshake.integ.test.ts` (mirrors `protocol-v2-handshake.integ.test.ts`) |
  | REQ-SRV-14 | `step()` still advances platform positions (regression — platforms not broken by carve-out) | unit | `pnpm -C packages/game-logic test step.platforms` | ✅ existing `packages/game-logic/test/step.test.ts` — add platform-advance assertion if not present |
  | REQ-CLI-04 | reconciler.onServerSnapshot is a no-op for self-player — sprite.setPosition + sprite.tweenTo never called (D-04 acceptance criterion) | unit | `pnpm -C apps/client test reconciler-self-noop` | Wave 0: NEW assertion added to `apps/client/src/__test__/reconciler.test.ts` |
  | REQ-CLI-04 | PositionDispatcher sends position_update at 30 Hz on tick, correctly populated from PredictionEngine + SpriteStateMachine output | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW `apps/client/src/__test__/position-dispatcher.test.ts` |
  | REQ-CLI-04 | predictor continues to drive local sprite position on every tick (regression — local feel preserved) | unit | `pnpm -C apps/client test prediction` | ✅ existing `apps/client/src/__test__/prediction.test.ts` |
  | REQ-CLI-04 | anim_state packing — 16 base poses round-trip pack→unpack | unit | `pnpm -C packages/protocol test anim-state` | Wave 0: NEW `packages/protocol/test/anim-state.test.ts` |
  | REQ-CLI-08 | two-player smoke: A moves, B sees A move smoothly (no rubber-band, no diagonal-stop drift, no hitching on simulated packet delay) | e2e | `pnpm -C apps/client test:e2e two-player-movement` | partial — covered by existing CLI-08 e2e; ADD assertion for diagonal-stop drift regression |
  | REQ-CLI-08 | manual operator UAT on staging — diagonal-stop drift bug closed; dropped-packet hitching bug closed | manual-only | (operator session — captures in `06.7-HUMAN-UAT.md`) | Wave 0: NEW operator UAT script |

  **Manual-only justification (REQ-CLI-08 row 2):** the original two motivating bugs (D-02) are subjective movement-feel issues caught by the operator's eye, not by automated thresholds. The automated CLI-08 e2e covers structural correctness (positions update, broadcast happens, two players see each other); operator UAT closes the feel question. Standard for 06.* operator-UAT-gated phases (see `04-HUMAN-UAT.md` precedent).

  ### Sampling Rate
  - **Per task commit:** `pnpm -C <affected-package> test` (server / client / protocol / game-logic — only the changed one)
  - **Per wave merge:** `pnpm -r test` (workspace-recursive) + `pnpm -r typecheck` + `pnpm trace:check`
  - **Phase gate:** Full suite green + `pnpm -C apps/server test:integration` green + `pnpm -C apps/client test:e2e` green + operator UAT signed off before `/gsd-verify-work`

  ### Wave 0 Gaps
  - [ ] `apps/server/test/position-update.test.ts` — unit tests for cPositionUpdateSchema.safeParse rejection paths + handler PlayerState writes — covers REQ-SRV-03
  - [ ] `apps/server/test/position-update.integ.test.ts` — two-client roundtrip integration test — covers REQ-SRV-03
  - [ ] `apps/server/test/sprite-override.test.ts` — handler + schema — covers REQ-SRV-03
  - [ ] `apps/server/test/protocol-v4-handshake.integ.test.ts` — version-bump rejection — covers REQ-SRV-03
  - [ ] `apps/client/src/__test__/position-dispatcher.test.ts` — dispatcher unit — covers REQ-CLI-04
  - [ ] `packages/protocol/test/anim-state.test.ts` — packAnimState/unpackAnimState round-trip — covers REQ-CLI-04
  - [ ] `apps/client/src/__test__/reconciler.test.ts` — ADD self-noop assertion (file exists; new test case) — covers REQ-CLI-04 D-04 acceptance
  - [ ] Update existing `apps/client/src/__test__/prediction.test.ts` — confirm predictor still drives sprite (regression guard) — covers REQ-CLI-04
  - [ ] `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — operator UAT script covering diagonal-stop drift + simulated-packet-delay hitching — covers REQ-CLI-08

  *Framework install: none — `vitest` already pinned and runs in every package.*

  ## Security Domain

  > Phase-relevant ASVS categories. REBNO's `security_enforcement` is implicit-on (no explicit `false` in config.json `workflow` block).

  ### Applicable ASVS Categories

  | ASVS Category | Applies | Standard Control |
  |---------------|---------|-----------------|
  | V2 Authentication | yes | Better-Auth + argon2id (unchanged — auth runs at room onAuth before any position_update can land) |
  | V3 Session Management | yes | Server-tagged identity from `client.auth.account_id`; wire-side identity REJECTED at zod parse (`.strict()`) — same pattern as existing `cInputSchema` |
  | V4 Access Control | yes | Server checks `auth` exists before accepting any position_update; rate-limit gate (token bucket) per (account_id, msg_type) |
  | V5 Input Validation | **yes — primary** | zod `cPositionUpdateSchema.strict()` validates every field, including int16 range on x/y/vx/vy, enum on facing, byte range on anim_state, monotonic on seq. Same pattern as existing schemas. |
  | V6 Cryptography | no | No new crypto in 06.7; manifest signatures for room layouts unchanged |

  ### Known Threat Patterns for {Colyseus 0.17 + Phaser 3.90 + zod stack}

  | Pattern | STRIDE | Standard Mitigation |
  |---------|--------|---------------------|
  | Forged sender identity on the wire (client claims to be another account) | Spoofing | `.strict()` on cPositionUpdateSchema rejects any extra fields including `account_id`/`pid`; server tags identity from `client.auth.account_id` (existing pattern, `onMessageHandlers.ts:107-110`) |
  | Malicious zod payload (huge / deeply-nested) | DoS | int16 bounds + 1-byte enum bounds + bounded string for facing enum + non-negative for seq cap memory cost per parse to bytes |
  | Position-update spam | DoS | Token bucket per (account_id, 'position_update') — same pattern as existing rate limits, suggested 35 tokens/sec / burst 60 |
  | Position spoofing for advantage (teleport) | Tampering | **Accepted in 06.7 per D-11** — fall-trigger reset is 06.8 mitigation; trusted operator pool in interim |
  | Stale / out-of-order updates from TCP reorder | Tampering (mild) | seq monotonic check: `if (parsed.data.seq <= player.last_input_seq) return;` |
  | `anim_state` corruption causing client renderer crash | Tampering | int range cap (0..255) + client-side enum-table lookup (already a defensive pattern in SpriteStateMachine: unknown frame keys would fall through to STAND_D) |

  **Threat acceptance note (D-11):** position spoofing is acknowledged as accepted risk. The threat-modeling row above is for documentation continuity; CLAUDE.md Hard Rule 1 carve-out per D-13 codifies the acceptance.

  ## Sources

  ### Primary (HIGH confidence)
  - **Codebase (this session):** apps/server/src/RebnoRoom.ts, apps/server/src/onMessageHandlers.ts, apps/client/src/prediction/reconciler.ts, apps/client/src/prediction/predictor.ts, apps/client/src/prediction/input-dispatcher.ts, apps/client/src/net/colyseus-client.ts, packages/protocol/src/{intents,state,events,version}.ts, packages/game-logic/src/{step,sprite-state-machine,constants,accumulator}.ts, apps/client/src/render/SpriteStateMachine.ts, package.json files
  - **CONTEXT.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md` — all D-01..D-16 decisions
  - **SEED.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-SEED.md` — original problem statement

  ### Secondary (MEDIUM confidence)
  - [Minecraft Wiki: Java Edition protocol/Packets](https://minecraft.wiki/w/Java_Edition_protocol/Packets) — Set Player Position, Set Player Position and Rotation, Synchronize Player Position packets (verified field layouts; byte estimates; clientbound vs serverbound)
  - [Minecraft Wiki: Java Edition protocol/FAQ](https://minecraft.wiki/w/Java_Edition_protocol/FAQ) — protocol overview
  - [Minecraft Wiki: Tick](https://minecraft.wiki/w/Tick) — "20 ticks per second" canonical rate; client position updates "on each tick"
  - [Quake 3 Source Code Review: Network Model (Fabien Sanglard)](https://fabiensanglard.net/quake3/network.php) — client-prediction + server-reconcile + delta-compression mechanics (Quake lineage comparator)
  - [Quake 3 Networking Primer (ra.is/unlagged)](https://www.ra.is/unlagged/network.html) — delta-compression and packet-loss recovery details
  - [Wikipedia: Client-side prediction](https://en.wikipedia.org/wiki/Client-side_prediction) — sequence-number reconciliation pattern (Pattern 4)
  - [Colyseus docs: State Synchronization](https://docs.colyseus.io/state) — property-level delta encoding; `setPatchRate` default 50ms
  - [Colyseus docs: WebSocket (Default) transport](https://docs.colyseus.io/server/transport/ws) — TCP-based reliable + ordered delivery confirmation
  - [Colyseus docs: Best Practices](https://docs.colyseus.io/state/best-practices) — delta-compression and binary-encoding overview

  ### Tertiary (LOW confidence — flagged for validation)
  - No tertiary-only sources used. Every claim is supported either by codebase grep (HIGH) or by at least one MEDIUM source corroborated against another.

  ## Metadata

  **Confidence breakdown:**
  - **Standard stack:** HIGH — every version pinned in this session against `package.json` files; no new dependencies
  - **Architecture:** HIGH — patterns derived from existing 06.4 D-58c set_facing precedent and verified Minecraft analog
  - **Wire schema:** HIGH — int16 bounds + zod patterns mirror existing `cInputSchema` and `cSetFacingSchema` exactly
  - **Pitfalls:** HIGH — direct from operator UAT logs (SEED), version-bump history (`version.ts` comments), Phaser tween idioms (in-file comments)
  - **Broadcast-cadence recommendation:** MEDIUM — bandwidth math is back-of-envelope; will validate against Fly.io staging telemetry post-deploy
  - **Server-side `step()` retention:** MEDIUM — recommendation is sound but A3 (platform-carry assumption) needs explicit handoff to Phase 7 planner

  **Research date:** 2026-05-17
  **Valid until:** 2026-06-17 (30 days — stable stack, no breaking version pins to track)

  ### Validation Strategy (VALIDATION.md)
  ---
  phase: 06.7
  slug: network-model-client-trust-fall-trigger
  status: draft
  nyquist_compliant: true
  wave_0_complete: false
  created: 2026-05-17
  ---

  # Phase 06.7 — Validation Strategy

  > Per-phase validation contract for feedback sampling during execution.
  > Substance extracted from `06.7-RESEARCH.md` §Validation Architecture (line 747) per
  > plan-checker iteration 1 blocker fix.

  ---

  ## Test Infrastructure

  | Property | Value |
  |----------|-------|
  | **Framework** | `vitest` 4.1.5 (server) / 3.2.4 (client) `[VERIFIED: package.json files]` |
  | **Config file** | Implicit `vitest` config in each app + workspace |
  | **Quick run command (server)** | `pnpm -C apps/server test` |
  | **Quick run command (client)** | `pnpm -C apps/client test` |
  | **Full suite command** | `pnpm -r test` (workspace-recursive) |
  | **Integration tests (server)** | `pnpm -C apps/server test:integration` |
  | **Estimated runtime** | ~45s per-package quick run; ~3min workspace-recursive |

  ---

  ## Sampling Rate

  - **After every task commit:** `pnpm -C <affected-package> test` (server / client / protocol / game-logic — only the changed one)
  - **After every plan wave:** `pnpm -r test` + `pnpm -r typecheck` + `pnpm trace:check`
  - **Before `/gsd-verify-work`:** Full suite green + `pnpm -C apps/server test:integration` green + `pnpm -C apps/client test:e2e` green + operator UAT signed off
  - **Max feedback latency:** < 60s for quick run; < 5min for wave gate

  ---

  ## Per-Task Verification Map (Phase Requirements → Test Map)

  | Req ID | Behavior | Test Type | Automated Command | File Exists? |
  |--------|----------|-----------|-------------------|-------------|
  | REQ-SRV-03 | server `position_update` handler validates with cPositionUpdateSchema (zod strict) and writes to PlayerState | unit | `pnpm -C apps/server test position-update` | Wave 0: NEW `apps/server/test/position-update.test.ts` |
  | REQ-SRV-03 | server `position_update` handler is rate-limited and rejects stale seq | unit | `pnpm -C apps/server test position-update-rate-limit` | Wave 0: NEW (covered in same file) |
  | REQ-SRV-03 | server `set_sprite_override` handler accepts valid sprite_id, writes to PlayerState.sprite_override | unit | `pnpm -C apps/server test sprite-override` | Wave 0: NEW `apps/server/test/sprite-override.test.ts` |
  | REQ-SRV-03 | end-to-end: client sends position_update → server stores → broadcasts → second client sees update | integration | `pnpm -C apps/server test:integration position-update` | Wave 0: NEW `apps/server/test/position-update.integ.test.ts` |
  | REQ-SRV-03 | PROTOCOL_VERSION 3 client rejected at handshake (close 4400) | integration | `pnpm -C apps/server test:integration protocol-v4-handshake` | Wave 0: NEW `apps/server/test/protocol-v4-handshake.integ.test.ts` (mirrors `protocol-v2-handshake.integ.test.ts`) |
  | REQ-SRV-14 | `step()` still advances platform positions (regression — platforms not broken by carve-out) | unit | `pnpm -C packages/game-logic test step.platforms` | ✅ existing `packages/game-logic/test/step.test.ts` — add platform-advance assertion if not present |
  | REQ-CLI-04 | reconciler.onServerSnapshot is a no-op for self-player — sprite.setPosition + sprite.tweenTo never called (D-04 acceptance criterion) | unit | `pnpm -C apps/client test reconciler-self-noop` | Wave 0: NEW assertion added to `apps/client/src/__test__/reconciler.test.ts` |
  | REQ-CLI-04 | PositionDispatcher sends position_update at 30 Hz on tick, correctly populated from PredictionEngine + SpriteStateMachine output | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW `apps/client/src/__test__/position-dispatcher.test.ts` |
  | REQ-CLI-04 | predictor continues to drive local sprite position on every tick (regression — local feel preserved) | unit | `pnpm -C apps/client test prediction` | ✅ existing `apps/client/src/__test__/prediction.test.ts` |
  | REQ-CLI-04 | anim_state packing — 16 base poses round-trip pack→unpack | unit | `pnpm -C packages/protocol test anim-state` | Wave 0: NEW `packages/protocol/test/anim-state.test.ts` |
  | REQ-CLI-04 | **D-06 idle cadence guard** — PositionDispatcher.sendTick emits valid payload when `getLocalState()` returns `{vx:0, vy:0}` (idle player still sends 30 Hz) | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW assertion in `apps/client/src/__test__/position-dispatcher.test.ts` (added per plan-checker iter 1 warning #3) |
  | REQ-CLI-08 | two-player smoke: A moves, B sees A move smoothly (no rubber-band, no diagonal-stop drift, no hitching on simulated packet delay) | e2e | `pnpm -C apps/client test:e2e two-player-movement` | partial — covered by existing CLI-08 e2e; ADD assertion for diagonal-stop drift regression |
  | REQ-CLI-08 | manual operator UAT on staging — diagonal-stop drift bug closed; dropped-packet hitching bug closed; idle-cadence ~30 Hz visible in DevTools Network | manual-only | (operator session — captures in `06.7-HUMAN-UAT.md`) | Wave 0: NEW operator UAT script |

  **Manual-only justification (REQ-CLI-08 row 2):** the two original motivating bugs (D-02) are subjective movement-feel issues caught by the operator's eye, not by automated thresholds. The automated CLI-08 e2e covers structural correctness (positions update, broadcast happens, two players see each other); operator UAT closes the feel question. Standard for 06.* operator-UAT-gated phases (see `04-HUMAN-UAT.md` precedent).

  ---

  ## Wave 0 Requirements

  - [ ] `apps/server/test/position-update.test.ts` — unit tests for `cPositionUpdateSchema.safeParse` rejection paths + handler `PlayerState` writes → REQ-SRV-03
  - [ ] `apps/server/test/position-update.integ.test.ts` — two-client roundtrip integration test → REQ-SRV-03
  - [ ] `apps/server/test/sprite-override.test.ts` — handler + schema → REQ-SRV-03
  - [ ] `apps/server/test/protocol-v4-handshake.integ.test.ts` — version-bump rejection → REQ-SRV-03
  - [ ] `apps/client/src/__test__/position-dispatcher.test.ts` — dispatcher unit (incl. D-06 idle-cadence assertion) → REQ-CLI-04
  - [ ] `packages/protocol/test/anim-state.test.ts` — `packAnimState`/`unpackAnimState` round-trip → REQ-CLI-04
  - [ ] `apps/client/src/__test__/reconciler.test.ts` — ADD self-noop assertion (file exists; new test case) → REQ-CLI-04 D-04 acceptance
  - [ ] Update existing `apps/client/src/__test__/prediction.test.ts` — confirm predictor still drives sprite (regression guard) → REQ-CLI-04
  - [ ] `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — operator UAT script covering diagonal-stop drift + simulated-packet-delay hitching + idle-cadence visibility → REQ-CLI-08

  *Framework install: none — `vitest` already pinned and runs in every package.*

  ---

  ## Manual-Only Verifications

  | Behavior | Requirement | Why Manual | Test Instructions |
  |----------|-------------|------------|-------------------|
  | Diagonal-stop drift closed (~10 px horizontal shift on stop no longer observed) | REQ-CLI-08 (D-02) | Movement-feel; needs human eye | Operator UAT Test 1 — walk diagonally on staging, release keys, observe no shift |
  | Dropped-packet hitching closed | REQ-CLI-08 (D-02) | Network-jitter perception | Operator UAT Test 2 — simulated packet delay via DevTools throttling; observe smooth interpolation |
  | Idle cadence visible at ~30 Hz | REQ-CLI-04 (D-06) | Wire-cadence visual confirmation | Operator UAT Test 6 (added per plan-checker iter 1 warning #3) — idle player; open DevTools Network panel; observe ~30 `position_update` frames/sec |
  | Rollback drill via `/gsd-undo` | REQ-CLI-08 (D-12, D-14) | Disaster-recovery rehearsal | Operator UAT Test 5 — run `/gsd-undo` on the 06.7 phase commits, verify staging reverts to 06.6 behavior |

  ---

  ## Validation Sign-Off

  - [x] All tasks have `<automated>` verify or Wave 0 dependencies
  - [x] Sampling continuity: no 3 consecutive tasks without automated verify
  - [x] Wave 0 covers all MISSING references
  - [x] No watch-mode flags
  - [x] Feedback latency < 60s for quick run
  - [x] `nyquist_compliant: true` set in frontmatter

  **Approval:** pending (plan-checker re-verification after iter 1 revision)

  ### Pattern Map (PATTERNS.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger — Pattern Map

  **Mapped:** 2026-05-17
  **Files analyzed:** 13 (5 modified + 6 new + 2 doctrine)
  **Analogs found:** 13 / 13

  ## File Classification

  | New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
  |-------------------|------|-----------|----------------|---------------|
  | `packages/protocol/src/intents.ts` | wire schema (MODIFY: add `cPositionUpdateSchema`, `cSetSpriteOverrideSchema`, `FACING_OCTANT`, `packAnimState`/`unpackAnimState`) | request-response (c2s) | `cSetFacingSchema` @ same file lines 93-98 + `cInputSchema` @ lines 45-59 | exact |
  | `packages/protocol/src/state.ts` | schema (MODIFY: add `anim_state` + `sprite_override` PlayerState fields) | event-driven (server→client state-diff) | `facing` field @ same file line 52 (06.4 D-58c precedent) | exact |
  | `packages/protocol/src/version.ts` | config (MODIFY: bump 3 → 4) | none | History block @ same file lines 14-22 | exact |
  | `apps/server/src/onMessageHandlers.ts` | server handler (MODIFY: add `position_update` + `set_sprite_override` handlers; extend rate-limit msg_type union) | request-response | `set_facing` handler @ same file lines 264-301 | exact |
  | `apps/server/src/RebnoRoom.ts` | room tick (MODIFY: tickLoop skips player input application; step() runs platforms only) | event-driven (per-tick) | Current tickLoop @ same file lines 1274-1315 | exact (in-place edit) |
  | `apps/client/src/prediction/position-dispatcher.ts` | client dispatcher (NEW) | request-response (per-tick send) | `InputDispatcher.sendInput` @ `apps/client/src/prediction/input-dispatcher.ts` lines 256-286 | exact |
  | `apps/client/src/prediction/reconciler.ts` | reconciler (MODIFY: self-player no-op via `isSelfPlayer` flag; remove globalThis ring-buffer for self) | event-driven (server-snapshot) | Current `ReconcileEngine.onServerSnapshot` @ same file lines 52-122 | exact (in-place edit) |
  | `apps/client/src/net/colyseus-client.ts` | client dispatcher wiring (MODIFY: instantiate PositionDispatcher; remove ReconcileEngine self-snapshot wiring) | event-driven | `bindHandlers` @ same file lines 237-258 | exact (in-place edit) |
  | `apps/server/test/position-update.test.ts` | test (NEW: zod + handler unit) | request-response | `apps/server/test/d58c-set-facing.integ.test.ts` + `packages/protocol/test/intents.test.ts` | exact |
  | `apps/server/test/position-update.integ.test.ts` | test (NEW: two-client integ) | request-response | `apps/server/test/event-driven-input.integ.test.ts` | exact |
  | `apps/server/test/sprite-override.test.ts` | test (NEW: handler + schema) | request-response | `apps/server/test/d58c-set-facing.integ.test.ts` | role-match |
  | `apps/server/test/protocol-v4-handshake.integ.test.ts` | test (NEW: version-bump rejection) | request-response | `apps/server/test/protocol-v2-handshake.integ.test.ts` | exact |
  | `apps/client/src/__test__/position-dispatcher.test.ts` | test (NEW: dispatcher unit) | request-response | `apps/client/src/__test__/input-dispatcher.test.ts` | exact |
  | `apps/client/src/__test__/reconciler.test.ts` | test (ADD case: D-04 self no-op assertion) | event-driven | Existing same file lines 52-93 | exact (additive case) |
  | `packages/protocol/test/anim-state.test.ts` | test (NEW: pack/unpack round-trip) | none | `packages/protocol/test/intents.test.ts` | role-match |
  | `packages/protocol/test/state.test.ts` | test (ADD case: anim_state + sprite_override defaults; bump PROTOCOL_VERSION === 4) | none | Existing same file lines 10-30 | exact (in-place edit) |
  | `CLAUDE.md` | doctrine (MODIFY: Hard Rule 1 movement carve-out per D-13) | none | Hard Rule 1 prose @ same file | exact |
  | `.planning/phases/06.7-.../06.7-HUMAN-UAT.md` | operator UAT script (NEW) | none | `.planning/phases/06.1-.../06.1-HUMAN-UAT.md` | role-match |

  ## Pattern Assignments

  ### `packages/protocol/src/intents.ts` (wire schema, c2s)

  **Analog:** `packages/protocol/src/intents.ts` itself — copy `cSetFacingSchema` and `cInputSchema` shape

  **Schema definition pattern** (lines 93-98, `cSetFacingSchema`):
  ```typescript
  export const cSetFacingSchema = z
    .object({
      type: z.literal('set_facing'),
      facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    })
    .strict();
  ```

  **Required-fields pattern** (lines 45-59, `cInputSchema` — keep `seq` + `monotonic_at_ms` precedent):
  ```typescript
  export const cInputSchema = z
    .object({
      type: z.literal('input'),
      seq: z.number().int().min(0),
      axes: z.object({ /* ... */ }).strict(),
      buttons_down: z.number().int().min(0).max(0xffff),
      buttons_up: z.number().int().min(0).max(0xffff),
      monotonic_at_ms: z.number().nonnegative(),
    })
    .strict();
  ```

  **Discriminated-union registration** (lines 120-127):
  ```typescript
  export const c2sSchema = z.discriminatedUnion('type', [
    cAuthSchema,
    cInputSchema,
    cChatSendSchema,
    cRoomJoinSchema,
    cHeartbeatSchema,
    cSetFacingSchema,
    // ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema
  ]);
  ```

  **Type-export pattern** (lines 129-135):
  ```typescript
  export type CSetFacing = z.infer<typeof cSetFacingSchema>;
  // ADD: CPositionUpdate, CSetSpriteOverride
  ```

  **Tag every export** with `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` per the precedent at line 83.

  ---

  ### `packages/protocol/src/state.ts` (schema, server→client)

  **Analog:** same file, `facing` field @ line 52 (06.4 D-58c additive precedent)

  **Additive field pattern** (line 52):
  ```typescript
  @type('string') facing: string = 'D';
  ```

  **Wire-version contract note** (lines 33-51 comment — copy this exact reasoning style for the new fields):
  > ADDITIVE FIELD — wire-compatible with Colyseus 0.17 schema-add (RESEARCH §Pitfall 2 + Assumption A2). Does NOT bump PROTOCOL_VERSION...

  For 06.7 the NEW fields DO trigger a PROTOCOL_VERSION bump (3 → 4) because the wire SHAPE (c2s.position_update) is genuinely new, not just an additive optional. The state.ts additions still follow the @colyseus/schema pattern:

  ```typescript
  // 06.7 NEW — add after `facing`:
  @type('number') anim_state: number = 0x06;  // default STAND_D (octant=6, running=0)
  @type('string') sprite_override: string = 'none';
  ```

  ---

  ### `packages/protocol/src/version.ts` (config)

  **Analog:** same file history block (lines 14-22)

  **Bump pattern** (line 24):
  ```typescript
  export const PROTOCOL_VERSION = 3 as const;
  // CHANGE to:
  export const PROTOCOL_VERSION = 4 as const;
  ```

  **History-block append pattern** (lines 14-22 — extend with v4 entry):
  ```typescript
  // History
  //  - 1: Phase 4 baseline (per-tick c2s.input shape + flat PlayerState)
  //  - 2: Phase 6 D-08/D-09 — adds PlayerState.axis_x_held/axis_y_held...
  //  - 3: Phase 6.2 D-51 — adds S2C force_reset variant...
  //  - 4: Phase 06.7 D-05/D-08/D-13 — flips player movement to CLIENT-AUTHORITATIVE
  //       absolute-position streaming. NEW c2s.position_update intent (30 Hz, int16
  //       quantized x/y/vx/vy + facing + anim_state + seq + monotonic_at_ms). NEW
  //       c2s.set_sprite_override event-only intent. PlayerState gains anim_state
  //       (u8 packed) + sprite_override (string). v3 clients close 4400 / MISMATCH.
  ```

  ---

  ### `apps/server/src/onMessageHandlers.ts` (server handler, request-response)

  **Analog:** same file, `set_facing` handler lines 264-301

  **Handler pattern with auth + rate-limit + zod + state-write** (lines 264-301):
  ```typescript
  ctx.room.onMessage('set_facing', (client: Client, raw: unknown) => {
    const auth = ctx.getAuth(client.sessionId);
    if (!auth) {
      log.warn({ sessionId: client.sessionId }, 'no_auth_for_session');
      return;
    }
    if (!rateLimitOrDrop(ctx, client, 'set_facing', auth.account_id)) return;
    const parsed = cSetFacingSchema.safeParse(raw);
    if (!parsed.success) {
      log.warn(
        { msg_type: 'set_facing', sessionId: client.sessionId, issue: parsed.error.message },
        'invalid_intent',
      );
      return;
    }
    const room = ctx.room as unknown as {
      state?: { players?: { get?: (sid: string) => { facing?: string } | undefined } };
    };
    const player = room.state?.players?.get?.(client.sessionId);
    if (!player) {
      log.warn(
        { sessionId: client.sessionId, account_id: auth.account_id },
        'set_facing_no_player_state',
      );
      return;
    }
    player.facing = parsed.data.facing;
  });
  ```

  **Rate-limit msg_type union extension** (line 72):
  ```typescript
  msg_type: 'input' | 'chat_send' | 'heartbeat' | 'room_join' | 'set_facing',
  // EXTEND TO:
  msg_type: 'input' | 'chat_send' | 'heartbeat' | 'room_join' | 'set_facing' | 'position_update' | 'set_sprite_override',
  ```

  **Stale-seq guard** (NEW for position_update only — derived from RESEARCH §Pattern 4):
  ```typescript
  // AFTER zod parse, BEFORE writing PlayerState:
  if (parsed.data.seq <= player.last_input_seq) return;
  player.last_input_seq = parsed.data.seq;
  player.x = parsed.data.x;
  player.y = parsed.data.y;
  // ... etc
  ```

  **Imports update** (lines 25-33):
  ```typescript
  import {
    cChatSendSchema,
    cInputSchema,
    cHeartbeatSchema,
    cRoomJoinSchema,
    cSetFacingSchema,
    encodeS2C,
    type S2C,
  } from '@rebno/protocol';
  // ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema
  ```

  ---

  ### `apps/server/src/RebnoRoom.ts` (room tick, event-driven)

  **Analog:** `tickLoop` in same file lines 1274-1315

  **Current player-input application pattern (TO REMOVE for self-player)** (lines 1289-1313):
  ```typescript
  for (let i = 0; i < ticks; i++) {
    const inputs = new Map<string, InputFrame>();
    for (const [account_id, frame] of this.inputBuffer) {
      inputs.set(account_id, frame);
    }
    this.inputBuffer.clear();
    for (const [account_id, held] of this.heldInputs) {
      inputs.set(account_id, { /* axis_x, axis_y, ... */ });
    }
    const next = step(this.toWorldState(), inputs, TICK_MS);
    this.applyToColyseusState(next);
  }
  ```

  **Target pattern after 06.7** (RESEARCH §Code Examples line 678-696):
  ```typescript
  for (let i = 0; i < ticks; i++) {
    // Server step() ONLY for platforms (REQ-SRV-14). Player position is
    // written by the position_update handler — pass empty inputs map.
    const next = step(this.toWorldState(), new Map(), TICK_MS);
    this.applyToColyseusState(next);
  }
  ```

  **Cleanup note:** the `heldInputs` map + `inputBuffer` writes are vestigial in 06.7 but KEPT per RESEARCH §Open Questions item 3 (06.9 cleanup deferred). The `c2s.input` handler still writes them; the tick loop just stops reading them for player position.

  ---

  ### `apps/client/src/prediction/position-dispatcher.ts` (client dispatcher, NEW)

  **Analog:** `InputDispatcher.sendInput` @ `apps/client/src/prediction/input-dispatcher.ts` lines 256-286

  **Class shape pattern** (lines 76-118 of input-dispatcher.ts — mutable `_room` + setter for reconnect):
  ```typescript
  export class InputDispatcher {
    private _room: Pick<Room, 'send'>;
    get room(): Pick<Room, 'send'> { return this._room; }
    setRoom(room: Pick<Room, 'send'>): void { this._room = room; }
    // ...
  }
  ```

  **Send-payload pattern** (lines 256-265):
  ```typescript
  private sendInput(axes: { x: Axis; y: Axis }): void {
    const intent = this.prediction.enqueueInput(axes);
    this._room.send('input', {
      type: 'input',
      seq: intent.seq,
      axes,
      buttons_down: 0,
      buttons_up: 0,
      monotonic_at_ms: intent.monotonic_at_ms,
    });
  }
  ```

  **Target shape (NEW) — RESEARCH §Code Examples 638-672:**
  ```typescript
  import type { Room } from '@colyseus/sdk';
  import type { PredictionEngine } from './predictor.js';
  import { packAnimState } from '@rebno/protocol';

  export class PositionDispatcher {
    private nextSeq = 0;
    constructor(
      private readonly room: Pick<Room, 'send'>,
      private readonly prediction: PredictionEngine,
      private readonly getFacing: () => Direction,
      private readonly getIsRunning: () => boolean,
    ) {}

    sendTick(monotonic_at_ms: number): void {
      const sim = this.prediction.getLocalState();
      const facing = this.getFacing();
      const isRunning = this.getIsRunning();
      this.room.send('position_update', {
        type: 'position_update',
        x: Math.round(sim.x),
        y: Math.round(sim.y),
        vx: Math.round(sim.vx),
        vy: Math.round(sim.vy),
        facing,
        anim_state: packAnimState(facing, isRunning),
        seq: ++this.nextSeq,
        monotonic_at_ms,
      });
    }
  }
  ```

  **Tag** with `// [impl->REQ-CLI-04] [impl->REQ-CLI-08]` per precedent at input-dispatcher.ts:1.

  ---

  ### `apps/client/src/prediction/reconciler.ts` (reconciler, MODIFY)

  **Analog:** same file `onServerSnapshot` lines 52-122

  **Self-player no-op pattern** (RESEARCH §Pattern 3 — replace the body with an early return when the snapshot is for self):
  ```typescript
  // Current signature: onServerSnapshot(snap: ServerSnapshot): void
  // CHANGE TO accept an isSelfPlayer flag (or detect via account_id comparison):
  onServerSnapshot(snap: ServerSnapshot, isSelfPlayer: boolean): void {
    if (isSelfPlayer) {
      // [impl->REQ-CLI-04] D-04 acceptance criterion: self-player sprite is
      // NEVER written by the snapshot path. Self-player position comes from
      // PredictionEngine.predictTick + InputDispatcher only.
      return;
    }
    // Remote-player path: currently unreachable from production callers
    // (PlayerRenderer.setRemotePosition handles remotes). Kept for symmetry.
  }
  ```

  **Drop the globalThis ring-buffer write** for the self-player branch (lines 73-92 + 100-119) — diagnostic surface is unused once the self-snapshot path is no-op. Mark `DIVERGENCE_THRESHOLD_PX = 22` with `@deprecated 06.7` per RESEARCH §State of the Art.

  **Header-comment update pattern** (lines 1-16):
  ```typescript
  // [impl->REQ-CLI-04]
  // 06.7 D-04 — ReconcileEngine.onServerSnapshot is a NO-OP for the self-player.
  // Self-player position is owned by the client (predictor.ts + PositionDispatcher);
  // snapshot-driven snap/lerp would re-introduce the diagonal-stop ~10 px drift
  // (operator UAT 2026-05-16). The remote-player branch is preserved for symmetry
  // but is not currently called — remote rendering goes through PlayerRenderer.
  ```

  ---

  ### `apps/client/src/net/colyseus-client.ts` (client dispatcher wiring, MODIFY)

  **Analog:** `bindHandlers` @ same file lines 237-258

  **Per-player onChange wiring** (lines 251-258):
  ```typescript
  players.onAdd((player: PlayerState, sessionId: string) => {
    if (sessionId === room.sessionId) cb.onLocalJoin?.(player, sessionId);
    else cb.onRemoteAdd?.(player, sessionId);
    $(player).onChange(() => {
      if (sessionId === room.sessionId) cb.onLocalSnapshot?.(player);
      else cb.onRemoteSnapshot?.(player, sessionId);
    });
  });
  ```

  **Modification:** Wire a `cb.onLocalSnapshot` callback that NO-OPS for self-player (or remove the callback entirely), and ensure `PositionDispatcher.sendTick(monotonic_at_ms)` is invoked from `GameScene.update` on the local sim-tick boundary. The dispatcher does NOT live in `bindHandlers` itself — instantiated where `InputDispatcher` is today and wired with the same `setRoom(room)` lifecycle for reconnect.

  **Pitfall 8 (RESEARCH §Pitfall 8):** the new `anim_state` / `sprite_override` PlayerState fields will fire `$(player).onChange` per-snapshot and the existing callback already reads "every field" — no new wiring needed beyond reading those values from `player.anim_state` / `player.sprite_override` in the renderer callbacks.

  ---

  ## Shared Patterns

  ### Authentication + identity (server-side)
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 104-110 (resolve auth FIRST before rate-limit / parse)
  **Apply to:** every new handler (`position_update`, `set_sprite_override`)
  ```typescript
  const auth = ctx.getAuth(client.sessionId);
  if (!auth) {
    log.warn({ sessionId: client.sessionId }, 'no_auth_for_session');
    return;
  }
  ```

  ### Rate-limit gate BEFORE zod parse
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 69-96 (`rateLimitOrDrop`) + line 112 usage
  **Apply to:** every new handler. RESEARCH §Pitfall 3 budgets: `position_update` → 35 tokens/sec / burst 60; `set_sprite_override` → existing default (rare event).
  ```typescript
  if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;
  ```

  ### `.strict()` zod validation + safeParse + warn
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 113-124
  **Apply to:** every new handler
  ```typescript
  const parsed = cPositionUpdateSchema.safeParse(raw);
  if (!parsed.success) {
    log.warn(
      { msg_type: 'position_update', sessionId: client.sessionId, issue: parsed.error.message },
      'invalid_intent',
    );
    return;
  }
  ```

  ### Server-tagged identity (never trust wire account_id)
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 183-188 (`auth.account_id`, NOT raw)
  **Apply to:** every new handler that writes to PlayerState. PlayerState key is `client.sessionId` (line 284), not a wire-supplied id.

  ### Tag-comment-then-code REQ traceability
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 246-247
  **Apply to:** every new handler/file
  ```typescript
  // [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]
  ctx.room.onMessage('position_update', (client: Client, raw: unknown) => { /* ... */ });
  ```

  ### Test file naming + tag convention
  **Source:** all test files under `apps/server/test/*.test.ts` (unit) + `*.integ.test.ts` (integration)
  **Apply to:** every new test
  - Header comment with `[unit->REQ-*]` or `[int->REQ-*]` tags (e.g., `apps/server/test/d58c-set-facing.integ.test.ts:1-3`)
  - `import { describe, it, expect, afterEach } from 'vitest';` (line 18 of d58c-set-facing.integ.test.ts)
  - `spawnServer()` helper from `./test-utils.js` for integ
  - `joinAs()` helper pattern (d58c-set-facing.integ.test.ts:38-59)
  - `mockAdapter()` / `makeMockRoom()` helpers for unit (reconciler.test.ts:32-50 + input-dispatcher.test.ts:13-29)
  - `vi.fn()` mocks + `mock.calls[N]` assertion style

  ## Per-test-file Pattern Assignments

  ### `apps/server/test/position-update.test.ts` (unit)
  **Analog:** `packages/protocol/test/intents.test.ts` lines 15-90 (zod accept/reject cases)
  **Apply:**
  - Mirror the 5 case-pattern: accepts new shape, accepts axis-edge, REJECTS legacy shape, REJECTS forged identity fields, REJECTS out-of-range.
  - Add stale-seq guard test (specific to 06.7).

  ### `apps/server/test/position-update.integ.test.ts` (integ)
  **Analog:** `apps/server/test/event-driven-input.integ.test.ts` lines 1-100
  **Apply:**
  - `joinClient()` helper (lines 35-53)
  - `positionOf(j)` accessor (lines 55-63) — same shape; read `x, y, anim_state, facing` from PlayerState map
  - two-client flow: client A sends position_update, await `positionOf(B's view of A)` matches

  ### `apps/server/test/sprite-override.test.ts` (handler unit)
  **Analog:** `apps/server/test/d58c-set-facing.integ.test.ts` lines 92-end (set_facing roundtrip)
  **Apply:** mirror set_facing assertion: send `set_sprite_override` with `sprite_id: 'hexport_in'`, assert `PlayerState.sprite_override === 'hexport_in'`.

  ### `apps/server/test/protocol-v4-handshake.integ.test.ts`
  **Analog:** `apps/server/test/protocol-v2-handshake.integ.test.ts` lines 1-68
  **Apply:**
  - `expect(PROTOCOL_VERSION).toBe(4);` (sanity)
  - v3 client receives close code 4400 with `PROTOCOL_VERSION_MISMATCH`
  - v4 client (current) succeeds
  - (Carry-forward the `client.joinOrCreate('rebno', { protocol_version: 3, session_token: 'dev-bypass' })` pattern at line 38.)

  ### `apps/client/src/__test__/position-dispatcher.test.ts`
  **Analog:** `apps/client/src/__test__/input-dispatcher.test.ts` lines 1-80
  **Apply:**
  - `makeMockRoom()` + `makeMockPrediction()` helpers (lines 13-29)
  - `vi.useFakeTimers()` setup/teardown (lines 36-46)
  - Per-call assertion: `room.send.mock.calls[0]![1]` for payload shape (line 52-58)
  - New cases: `sendTick(t)` emits payload matching cPositionUpdateSchema; seq monotonically increments; packAnimState produces correct byte for facing+running combo.

  ### `apps/client/src/__test__/reconciler.test.ts` (ADD case)
  **Analog:** existing same file lines 52-93 (lerp + hard-snap cases)
  **Apply (D-04 acceptance criterion):**
  ```typescript
  it('D-04 06.7 — onServerSnapshot is a NO-OP for self-player (no setPosition, no tweenTo)', () => {
    const m = mockAdapter(100, 100);
    const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
    // Server says (500, 500) — would have triggered hard snap pre-06.7.
    eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true);
    expect(m.setPosition).not.toHaveBeenCalled();
    expect(m.tweenTo).not.toHaveBeenCalled();
  });
  ```

  ### `packages/protocol/test/anim-state.test.ts` (NEW)
  **Analog:** `packages/protocol/test/intents.test.ts` lines 1-15 (header + import style)
  **Apply:**
  - Loop the 16 base poses; round-trip `packAnimState(facing, running)` → `unpackAnimState(byte)` returns same `{facing, running}`.
  - Bit-layout assertions: `packAnimState('D', false) === 0x06`, `packAnimState('DR', true) === 0x0F`, etc. (Reference: RESEARCH §anim_state byte layout table.)

  ### `packages/protocol/test/state.test.ts` (MODIFY)
  **Analog:** existing same file lines 10-30
  **Apply:**
  - Update version literal test: `expect(PROTOCOL_VERSION).toBe(4);`
  - Add defaults for new fields:
    ```typescript
    it('PlayerState defaults — anim_state starts at 0x06 (STAND_D), sprite_override = "none"', () => {
      const p = new PlayerState();
      expect(p.anim_state).toBe(0x06);
      expect(p.sprite_override).toBe('none');
    });
    ```

  ---

  ## CLAUDE.md Hard Rule 1 carve-out (doctrine — D-13)

  **Location:** `CLAUDE.md` §"Hard Rules" Rule 1

  **Current text (locate by Grep):**
  > 1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.

  **Replacement (D-13 — drop scores/combat; carve out movement):**
  > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8.

  Tag the paragraph: `[doc->REQ-SRV-04]` (or whichever REQ traces to Rule 1; verify in `traceable-reqs.toml`).

  ---

  ## `.planning/phases/06.7-.../06.7-HUMAN-UAT.md` (operator UAT script)

  **Analog:** `.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md`

  **Apply:** copy the structure (preconditions, step-by-step operator actions, expected vs observed columns, sign-off section). Cover RESEARCH §Validation row "REQ-CLI-08 manual-only":
  - Diagonal-stop drift bug closed (no ~10 px shift on stop)
  - Dropped-packet hitching closed (simulated 5% loss via Chrome DevTools network conditions — verify smooth visual)
  - Two-player smoke (A moves, B sees A move smoothly; no rubber-band)

  ---

  ## No Analog Found

  None. Every file has a clear precedent inside the repository — this phase is a pure refactor of established patterns. The `position_update` flow is structurally identical to `set_facing` (D-58c) scaled to 30 Hz with int16 fields; the `set_sprite_override` flow is `set_facing` with a different enum; tests mirror existing 06.4 / 06.6 patterns.

  ## Metadata

  **Analog search scope:**
  - `packages/protocol/src/` + `packages/protocol/test/`
  - `apps/server/src/` + `apps/server/test/`
  - `apps/client/src/prediction/` + `apps/client/src/net/` + `apps/client/src/__test__/`
  - `.planning/phases/06.*/` for HUMAN-UAT precedent

  **Files scanned (direct Read):** 11
  - intents.ts, state.ts, version.ts, onMessageHandlers.ts (full + targeted), RebnoRoom.ts (tickLoop region), reconciler.ts (full), input-dispatcher.ts (header + sendInput region), colyseus-client.ts (bindHandlers region), reconciler.test.ts (head), input-dispatcher.test.ts (head), intents.test.ts (head), state.test.ts (head), d58c-set-facing.integ.test.ts (head), protocol-v2-handshake.integ.test.ts (full), event-driven-input.integ.test.ts (head)

  **Pattern extraction date:** 2026-05-17

  ### Plans to Review

  ---
  #### 06.7-01-PLAN.md
  ---
  phase: 06.7
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - packages/protocol/src/intents.ts
    - packages/protocol/src/state.ts
    - packages/protocol/src/version.ts
    - packages/protocol/src/anim-state.ts
    - packages/protocol/src/index.ts
    - packages/protocol/test/anim-state.test.ts
    - packages/protocol/test/intents.test.ts
    - packages/protocol/test/state.test.ts
  autonomous: true
  requirements:
    - REQ-SRV-03
    - REQ-CLI-04
    - REQ-CLI-08
  must_haves:
    truths:
      - "Protocol exposes cPositionUpdateSchema accepting {type:'position_update', x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms} with int16 bounds + .strict()"
      - "Protocol exposes cSetSpriteOverrideSchema accepting {type:'set_sprite_override', sprite_id} as a closed enum"
      - "packAnimState(facing, isRunning) and unpackAnimState(byte) round-trip cleanly for all 16 base poses; STAND_D = 0x06, RUN_DR = 0x0F"
      - "PlayerState gains anim_state:number (default 0x06) and sprite_override:string (default 'none')"
      - "PROTOCOL_VERSION is the literal 4; history block records the 06.7 D-05/D-08/D-13 reason"
      - "c2sSchema discriminatedUnion includes cPositionUpdateSchema and cSetSpriteOverrideSchema"
    artifacts:
      - path: "packages/protocol/src/intents.ts"
        provides: "cPositionUpdateSchema + cSetSpriteOverrideSchema + CPositionUpdate/CSetSpriteOverride types"
        contains: "cPositionUpdateSchema"
      - path: "packages/protocol/src/anim-state.ts"
        provides: "FACING_OCTANT record, packAnimState(facing, isRunning), unpackAnimState(byte)"
        contains: "packAnimState"
      - path: "packages/protocol/src/state.ts"
        provides: "PlayerState with anim_state:number and sprite_override:string"
        contains: "anim_state"
      - path: "packages/protocol/src/version.ts"
        provides: "PROTOCOL_VERSION = 4"
        contains: "PROTOCOL_VERSION = 4"
      - path: "packages/protocol/test/anim-state.test.ts"
        provides: "Pack/unpack round-trip + bit-layout assertions"
        contains: "round-trip"
    key_links:
      - from: "packages/protocol/src/index.ts"
        to: "packages/protocol/src/anim-state.ts"
        via: "barrel export"
        pattern: "anim-state"
      - from: "packages/protocol/src/intents.ts"
        to: "c2sSchema discriminatedUnion"
        via: "cPositionUpdateSchema entry"
        pattern: "cPositionUpdateSchema"
      - from: "packages/protocol/src/intents.ts"
        to: "FACING_OCTANT"
        via: "import from anim-state.ts"
        pattern: "from './anim-state"
  ---

  <objective>
  Land the protocol surface that all downstream 06.7 work consumes: new c2s schemas (`cPositionUpdateSchema`, `cSetSpriteOverrideSchema`), the `packAnimState`/`unpackAnimState`/`FACING_OCTANT` helpers, additive `PlayerState` fields (`anim_state`, `sprite_override`), and the PROTOCOL_VERSION 3 → 4 bump. Plans 02 (server) and 03 (client) compile against this surface.

  Purpose: D-05 / D-08 / D-09 / D-10 / D-13 wire-shape decisions are realised here. Interface-first task ordering — define contracts before consumers exist.
  Output: New file `packages/protocol/src/anim-state.ts`; modified `intents.ts`, `state.ts`, `version.ts`, `index.ts`; tests covering round-trip + schema accept/reject + state defaults + version literal.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @packages/protocol/src/intents.ts
  @packages/protocol/src/state.ts
  @packages/protocol/src/version.ts
  @packages/protocol/src/index.ts
  @packages/protocol/test/intents.test.ts
  @packages/protocol/test/state.test.ts

  <interfaces>
  <!-- Authoritative contracts for downstream Plans 02 + 03. Executor should treat -->
  <!-- these as the spec; no codebase exploration required to know shape. -->

  cPositionUpdateSchema (NEW — packages/protocol/src/intents.ts) — per D-05/D-07/D-08/D-10 + RESEARCH §"Recommended position_update Wire Schema":
  - type: literal 'position_update'
  - x:  z.number().int().min(-32768).max(32767)
  - y:  z.number().int().min(-32768).max(32767)
  - vx: z.number().int().min(-32768).max(32767)
  - vy: z.number().int().min(-32768).max(32767)
  - facing: z.enum(['D','DR','R','UR','U','UL','L','DL'])
  - anim_state: z.number().int().min(0).max(255)
  - seq: z.number().int().min(0)
  - monotonic_at_ms: z.number().nonnegative()
  - .strict()

  cSetSpriteOverrideSchema (NEW) — per D-09 + RESEARCH §"set_sprite_override schema":
  - type: literal 'set_sprite_override'
  - sprite_id: z.enum(['hexport_in','hexport_out','tele_in','tele_out','ncol_1','ncol_2','ncol_3','ncol_4','jokershell','watching','none'])
  - .strict()

  FACING_OCTANT (NEW — packages/protocol/src/anim-state.ts):
  - R:0, UR:1, U:2, UL:3, L:4, DL:5, D:6, DR:7

  packAnimState(facing: Direction, isRunning: boolean): number
  - byte = FACING_OCTANT[facing] | (isRunning ? 0x08 : 0)
  unpackAnimState(byte: number): { facing: Direction; running: boolean }
  - octant = byte & 0x07; running = (byte & 0x08) !== 0; facing = inverse table

  PlayerState additive fields (state.ts) — per D-08 + RESEARCH §"Pitfall 5":
    @type('number') anim_state: number = 0x06;       // STAND_D default
    @type('string') sprite_override: string = 'none';

  PROTOCOL_VERSION literal change: `export const PROTOCOL_VERSION = 4 as const;`
  History block entry text — RESEARCH §"Bump pattern".
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: anim-state module + protocol barrel export + round-trip test</name>
    <read_first>
      - packages/protocol/src/index.ts (current barrel exports — confirm pattern for adding a new module)
      - packages/protocol/src/intents.ts (Direction type / facing enum origin — confirm where 'D','DR','R','UR','U','UL','L','DL' is canonically declared so anim-state.ts can import or re-declare consistently)
      - packages/protocol/test/intents.test.ts (test file header conventions, vitest imports, test-tagging style)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"anim_state byte layout (packed 1-byte enum)" and §"facing enum (octant index)" (source-of-truth byte values: STAND_D=0x06, RUN_DR=0x0F, all 16 base poses)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/test/anim-state.test.ts (NEW)"
    </read_first>
    <behavior>
      - packAnimState('D', false) === 0x06
      - packAnimState('DR', false) === 0x07
      - packAnimState('R', false) === 0x00
      - packAnimState('UR', false) === 0x01
      - packAnimState('U', false) === 0x02
      - packAnimState('UL', false) === 0x03
      - packAnimState('L', false) === 0x04
      - packAnimState('DL', false) === 0x05
      - packAnimState('R', true) === 0x08
      - packAnimState('DR', true) === 0x0F
      - packAnimState('D', true) === 0x0E
      - Round-trip: for all 8 facings × {true,false}, unpackAnimState(packAnimState(f, r)) deep-equals { facing: f, running: r }
      - unpackAnimState(0x06) deep-equals { facing: 'D', running: false }
      - unpackAnimState(0x0F) deep-equals { facing: 'DR', running: true }
      - FACING_OCTANT has all 8 keys mapped to the documented 0..7 values
    </behavior>
    <action>
      Create `packages/protocol/src/anim-state.ts` exporting (a) the `FACING_OCTANT` constant record with the 8 keys mapped to 0..7 per the table in RESEARCH §"facing enum (octant index)", (b) an `OCTANT_FACING` inverse lookup (string[8]) for unpack, (c) `packAnimState(facing, isRunning)` returning `FACING_OCTANT[facing] | (isRunning ? 0x08 : 0)`, (d) `unpackAnimState(byte)` returning `{ facing: OCTANT_FACING[byte & 0x07], running: (byte & 0x08) !== 0 }`. The `Direction` type alias is imported from `./intents.ts` if it already exists there; if not, declare a local `Direction = 'D' | 'DR' | 'R' | 'UR' | 'U' | 'UL' | 'L' | 'DL'` and re-export it from anim-state.ts so other modules (PositionDispatcher in Plan 03) can import a single canonical name. Tag the module header `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]`. Add a comment block referencing RESEARCH §"anim_state byte layout" with the full byte-table for future reference.

      Update `packages/protocol/src/index.ts` to re-export the new module: add `export * from './anim-state.js';` near the existing barrel exports.

      Create `packages/protocol/test/anim-state.test.ts`. Follow the file-header tagging style from `packages/protocol/test/intents.test.ts:1-3` with `// [unit->REQ-CLI-04] [unit->REQ-SRV-03]`. Import vitest `describe / it / expect`. Cover the 13 behaviors enumerated above (8 stand bytes + 2-3 named run bytes + round-trip loop + FACING_OCTANT keys). Use a for-loop to iterate all 8 facings × {true,false} for the round-trip; assert per-iteration with `expect(unpackAnimState(packAnimState(f, r))).toEqual({ facing: f, running: r })`.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/anim-state.test.ts</automated>
    </verify>
    <done>anim-state.ts compiles, exports the three named symbols + Direction + OCTANT_FACING; round-trip test green; barrel exports the module so `import { packAnimState } from '@rebno/protocol'` resolves.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "export.*packAnimState" packages/protocol/src/anim-state.ts` matches at least one line
      - Source assertion: `grep -n "FACING_OCTANT" packages/protocol/src/anim-state.ts` matches at least one line
      - Source assertion: `grep -n "from './anim-state" packages/protocol/src/index.ts` matches (barrel updated)
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/anim-state.test.ts` exits 0 and reports the round-trip loop (16 cases) plus the named-byte assertions all passing
      - Tag assertion: `grep -n "impl->REQ-CLI-04" packages/protocol/src/anim-state.ts` matches at least one line
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: cPositionUpdateSchema + cSetSpriteOverrideSchema + discriminated-union registration + intents tests</name>
    <read_first>
      - packages/protocol/src/intents.ts (entire file — cSetFacingSchema @ lines 93-98, cInputSchema @ 45-59, c2sSchema discriminated-union @ 120-127, type-export pattern @ 129-135 are the analogs)
      - packages/protocol/test/intents.test.ts (existing accept/reject test pattern)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/src/intents.ts" (exact analog locations)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Recommended position_update Wire Schema" + §"set_sprite_override schema (rare event)"
    </read_first>
    <behavior>
      - cPositionUpdateSchema.safeParse(valid 18 B payload) returns success=true
      - cPositionUpdateSchema rejects x = 32768 (out of int16 range)
      - cPositionUpdateSchema rejects x = -32769
      - cPositionUpdateSchema rejects facing = 'X' (not in enum)
      - cPositionUpdateSchema rejects anim_state = 256 (out of u8 range)
      - cPositionUpdateSchema rejects anim_state = -1
      - cPositionUpdateSchema rejects seq = -1
      - cPositionUpdateSchema rejects monotonic_at_ms = -1
      - cPositionUpdateSchema rejects extra fields per .strict() (e.g. `account_id: 'attacker'`)
      - cPositionUpdateSchema rejects missing required field (e.g. drop `seq`)
      - cSetSpriteOverrideSchema accepts every documented sprite_id including 'none'
      - cSetSpriteOverrideSchema rejects sprite_id = 'unknown'
      - cSetSpriteOverrideSchema rejects extra fields (.strict)
      - c2sSchema.parse({ type:'position_update', ... }) routes to cPositionUpdateSchema
      - c2sSchema.parse({ type:'set_sprite_override', sprite_id:'hexport_in' }) routes correctly
    </behavior>
    <action>
      In `packages/protocol/src/intents.ts`:
      - Add `cPositionUpdateSchema` after `cSetFacingSchema`. Field-by-field per the `<interfaces>` block above. Use `.strict()`. Tag the export line with `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` per the precedent at intents.ts:83.
      - Add `cSetSpriteOverrideSchema` immediately after `cPositionUpdateSchema`. Use `.strict()`. Same tagging.
      - Extend the existing `c2sSchema = z.discriminatedUnion('type', [...])` array (lines 120-127 today) by appending `cPositionUpdateSchema` and `cSetSpriteOverrideSchema`. KEEP `cInputSchema` in the union per RESEARCH §"Adding cPositionUpdateSchema to the discriminated-union" (pauseMovement contract + test harness path).
      - Export TS types: `export type CPositionUpdate = z.infer<typeof cPositionUpdateSchema>;` and `export type CSetSpriteOverride = z.infer<typeof cSetSpriteOverrideSchema>;` near the existing `CSetFacing` export at lines 129-135.

      Update `packages/protocol/test/intents.test.ts`:
      - Add a `describe('cPositionUpdateSchema (06.7 D-05)', ...)` block. Each `it` covers one of the 10 position_update behaviors enumerated above. Use the existing accept/reject style (`expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true/false)`).
      - Add a `describe('cSetSpriteOverrideSchema (06.7 D-09)', ...)` block covering the 3 override behaviors.
      - Add 2 `it` cases under an existing or new `describe('c2sSchema discriminated-union')` block: position_update routing + set_sprite_override routing.

      Do NOT yet touch `state.ts` or `version.ts` — those are Task 3.

      Tags on each new `it` body: `// [unit->REQ-SRV-03]` for handler-side intent validation; `// [unit->REQ-CLI-04]` where the field originates client-side.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/intents.test.ts</automated>
    </verify>
    <done>Both new schemas exported and reachable from `@rebno/protocol` consumers; discriminated-union dispatches by `type` correctly; all new tests pass; existing intents tests still green.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "cPositionUpdateSchema" packages/protocol/src/intents.ts` matches export + union-array references
      - Source assertion: `grep -n "cSetSpriteOverrideSchema" packages/protocol/src/intents.ts` matches export + union-array references
      - Source assertion: `grep -n "\.strict()" packages/protocol/src/intents.ts | grep -E "(cPositionUpdateSchema|cSetSpriteOverrideSchema)" -B1` shows .strict() on both new schemas (or, simpler, the new schema definitions in the file end with `.strict()` — visually confirm via file diff)
      - Source assertion: `grep -c "cInputSchema" packages/protocol/src/intents.ts` returns ≥ 2 (still present after the union edit)
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/intents.test.ts` exits 0; the new describes report at least 14 passing assertions total (10 position_update + 3 set_sprite_override + 2 c2s union routes — count is a floor, more is fine)
      - Tag assertion: `grep -n "impl->REQ-CLI-04" packages/protocol/src/intents.ts` matches at least one line in the new region
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: PlayerState anim_state + sprite_override fields + PROTOCOL_VERSION bump 3 → 4 + state.test updates</name>
    <read_first>
      - packages/protocol/src/state.ts (full file — `facing` field @ line 52 is the additive analog; the wire-version contract comment block @ lines 33-51 documents the additive convention)
      - packages/protocol/src/version.ts (full file — history block @ lines 14-22 + literal @ line 24)
      - packages/protocol/test/state.test.ts (existing version-literal + defaults tests @ lines 10-30)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/src/state.ts" + §"packages/protocol/src/version.ts" (exact text for the history-block append)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pitfall 5" (PlayerState extension discipline)
    </read_first>
    <behavior>
      - `new PlayerState().anim_state === 0x06`
      - `new PlayerState().sprite_override === 'none'`
      - `PROTOCOL_VERSION === 4`
      - Existing PlayerState defaults tests still pass (facing default unchanged)
    </behavior>
    <action>
      In `packages/protocol/src/state.ts`:
      - Add two `@type(...)` decorated fields on `PlayerState`, placed immediately after the existing `facing` field at line 52 to mirror the 06.4 D-58c precedent:
        ```
        @type('number') anim_state: number = 0x06;
        @type('string') sprite_override: string = 'none';
        ```
      - Extend the wire-version contract comment block above the class (lines 33-51 region) with a 06.7 entry noting: ADDITIVE (still wire-compatible with v3 state-diff per Pitfall 5), default 0x06 = STAND_D, default 'none' clears any override, populated by the new c2s.position_update / c2s.set_sprite_override handlers added in Plan 02.
      - Do NOT change `last_input_seq`, `vx`, `vy`, `facing` (already present and reused).

      In `packages/protocol/src/version.ts`:
      - Change `export const PROTOCOL_VERSION = 3 as const;` to `export const PROTOCOL_VERSION = 4 as const;` (existing literal at line 24).
      - Append a new history entry under the v3 line, matching the exact wording in PATTERNS.md §"version.ts" block:
        ```
        //  - 4: Phase 06.7 D-05/D-08/D-13 — flips player movement to CLIENT-AUTHORITATIVE
        //       absolute-position streaming. NEW c2s.position_update intent (30 Hz, int16
        //       quantized x/y/vx/vy + facing + anim_state + seq + monotonic_at_ms). NEW
        //       c2s.set_sprite_override event-only intent. PlayerState gains anim_state
        //       (u8 packed) + sprite_override (string). v3 clients close 4400 / MISMATCH.
        ```

      Update `packages/protocol/test/state.test.ts`:
      - Update the existing PROTOCOL_VERSION literal test (lines 10-30 region) to assert `expect(PROTOCOL_VERSION).toBe(4);`.
      - Add an `it` case asserting the two new defaults:
        `expect(new PlayerState().anim_state).toBe(0x06); expect(new PlayerState().sprite_override).toBe('none');`
      - Tag the modified file header with `// [unit->REQ-SRV-03] [unit->REQ-CLI-04]` if not already present.

      Note: this task lands the version bump but does NOT yet add the protocol-v4-handshake integration test — that lives in Plan 02 alongside server changes that produce the rejection path.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/state.test.ts && pnpm exec tsc --noEmit</automated>
    </verify>
    <done>PROTOCOL_VERSION literal = 4; PlayerState defaults match D-08 + D-09; existing facing default test still green; pnpm trace:check passes for protocol package.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "PROTOCOL_VERSION = 4 as const" packages/protocol/src/version.ts` matches exactly once
      - Source assertion: `grep -nE "^//  - 4: Phase 06.7" packages/protocol/src/version.ts` matches the new history line
      - Source assertion: `grep -n "anim_state: number = 0x06" packages/protocol/src/state.ts` matches
      - Source assertion: `grep -n "sprite_override: string = 'none'" packages/protocol/src/state.ts` matches
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/state.test.ts` exits 0; the PROTOCOL_VERSION test asserts 4; the new defaults `it` passes
      - Build assertion: `pnpm -C packages/protocol exec tsc --noEmit` exits 0
      - Trace assertion: `pnpm trace:check` does not regress on REQ-SRV-03 / REQ-CLI-04 doc stages (does not fail the build)
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server (`Room.send`) | Untrusted JSON crosses into the room handler; zod is the gate |
  | protocol package consumers | Schema exports must be `.strict()` to deny extra fields by default |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-01a | Tampering | `cPositionUpdateSchema` field bounds | mitigate | Per D-11, server-side SEMANTIC validation of position legality is OUT OF SCOPE — relegated to candidate Phase 06.8. Defense-in-depth at the protocol boundary is still enforced: int16 bounds on x/y/vx/vy (caps malicious unbounded ints), u8 bound on anim_state, enum membership on facing, non-negative on seq + monotonic_at_ms. Implemented via zod `z.number().int().min(-32768).max(32767)` etc.; rejection happens at handler `safeParse` before any state write. |
  | T-06.7-02 | Tampering / Spoofing | `cSetSpriteOverrideSchema` sprite_id | mitigate | `z.enum([...])` is a closed set; `.strict()` rejects extras. Any non-listed sprite_id is rejected at handler-side `safeParse` (Plan 02). |
  | T-06.7-04 | Spoofing | wire-side identity fields (e.g. attacker submits `{ account_id: 'victim' }` on position_update) | mitigate | `.strict()` on every new schema rejects EVERY extra field; server consumes identity from `client.auth.account_id`, never wire. Confirmed by intents.test.ts case "REJECTS forged identity fields" (Task 2). |
  | T-06.7-05 | DoS | giant nested payloads | mitigate | int16 bounds + 1-byte enum + bounded enum strings + non-negative integers — every field is finite, so memory cost per parse is constant. No string-without-length, no deep nesting allowed. |

  **Doctrine change note (D-13):** This plan does NOT modify CLAUDE.md — the Hard Rule 1 carve-out edit lives in Plan 04. But the schemas defined here ARE the substrate that the doctrine change describes: the client-trust model is implemented in protocol shape before doctrine is updated. Plan 04 makes the rule explicit.
  </threat_model>

  <verification>
  - `pnpm -C packages/protocol exec vitest run` green across all protocol test files (intents, state, anim-state, events, version)
  - `pnpm -C packages/protocol exec tsc --noEmit` clean
  - `pnpm trace:check` does not regress
  - `grep -c "PROTOCOL_VERSION = 4" packages/protocol/src/version.ts` returns exactly 1
  - `grep -c "cPositionUpdateSchema" packages/protocol/src/intents.ts | grep -v '^#'` returns ≥ 3 (definition + type infer + discriminated-union entry)
  </verification>

  <success_criteria>
  - All three tasks land their files; tests for each green
  - Downstream Plans 02 + 03 can `import { cPositionUpdateSchema, cSetSpriteOverrideSchema, packAnimState, unpackAnimState, FACING_OCTANT, type CPositionUpdate, type CSetSpriteOverride } from '@rebno/protocol'` with no codebase exploration
  - PROTOCOL_VERSION = 4 visible from any consumer (handshake-rejection test in Plan 02 imports it directly)
  - No regression to existing intents/state tests
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-SUMMARY.md` per the template — call out what consumers can import, the byte-layout reference for future protocol readers, and the v3→v4 history entry for archival.
  </output>

  ---
  #### 06.7-02-PLAN.md
  ---
  phase: 06.7
  plan: 02
  type: execute
  wave: 2
  depends_on:
    - 06.7-01
  files_modified:
    - apps/server/src/onMessageHandlers.ts
    - apps/server/src/RebnoRoom.ts
    - packages/game-logic/src/step.ts
    - apps/server/test/position-update.test.ts
    - apps/server/test/position-update.integ.test.ts
    - apps/server/test/sprite-override.test.ts
    - apps/server/test/protocol-v4-handshake.integ.test.ts
    - packages/game-logic/test/step.test.ts
  autonomous: true
  requirements:
    - REQ-SRV-03
    - REQ-SRV-14
    - REQ-CLI-08
  must_haves:
    truths:
      - "Server accepts c2s.position_update from an authed client; payload mutates that client's PlayerState (x, y, vx, vy, facing, anim_state, last_input_seq)"
      - "Server accepts c2s.set_sprite_override from an authed client; payload mutates that client's PlayerState.sprite_override"
      - "Server rate-limits position_update per (account_id, msg_type='position_update') via the existing token bucket (~35 tokens/s, burst 60)"
      - "Server rejects out-of-order position_update by seq <= last_input_seq"
      - "Server tickLoop runs step() with an empty inputs Map — player position is NOT derived from heldInputs"
      - "Server step() still advances platform positions per (REQ-SRV-14)"
      - "PROTOCOL_VERSION 3 client is rejected at handshake (close code 4400 / PROTOCOL_VERSION_MISMATCH); v4 client succeeds"
      - "Two-client integ: client A sends position_update → B observes PlayerState diff for A within one broadcast cycle (~50ms)"
    artifacts:
      - path: "apps/server/src/onMessageHandlers.ts"
        provides: "position_update + set_sprite_override Colyseus onMessage handlers"
        contains: "position_update"
      - path: "apps/server/src/RebnoRoom.ts"
        provides: "tickLoop with empty-inputs Map; step() runs platforms only"
        contains: "new Map()"
      - path: "packages/game-logic/src/step.ts"
        provides: "Players pass-through; platform advance only"
        contains: "REQ-SRV-14"
      - path: "apps/server/test/position-update.test.ts"
        provides: "Unit tests for zod schema rejection + handler PlayerState writes + stale-seq guard"
        contains: "describe"
      - path: "apps/server/test/position-update.integ.test.ts"
        provides: "Two-client roundtrip integration test"
        contains: "joinClient"
      - path: "apps/server/test/sprite-override.test.ts"
        provides: "Handler unit + schema acceptance test"
        contains: "set_sprite_override"
      - path: "apps/server/test/protocol-v4-handshake.integ.test.ts"
        provides: "v3 client rejected; v4 client accepted at handshake"
        contains: "PROTOCOL_VERSION_MISMATCH"
    key_links:
      - from: "apps/server/src/onMessageHandlers.ts"
        to: "PlayerState.{x,y,vx,vy,facing,anim_state,last_input_seq,sprite_override}"
        via: "Direct field writes after zod safeParse + auth + rate-limit"
        pattern: "player\\.(x|y|vx|vy|facing|anim_state|last_input_seq|sprite_override)\\s*="
      - from: "apps/server/src/RebnoRoom.ts tickLoop"
        to: "step(toWorldState(), new Map(), TICK_MS)"
        via: "Empty inputs Map (was heldInputs-derived)"
        pattern: "step\\(.*new Map\\(\\)"
      - from: "packages/game-logic/src/step.ts"
        to: "platforms advance loop only (player pass-through)"
        via: "input-axis decoding block removed for players; platforms unchanged"
        pattern: "newPlatforms"
  ---

  <objective>
  Wire the server side of the client-trust flip: add `position_update` and `set_sprite_override` Colyseus handlers; modify the room tickLoop to stop deriving player position from `heldInputs`; trim `step()`'s player branch to a pass-through while preserving the platforms branch (REQ-SRV-14); ship integration tests covering the two-client smoke + protocol v3 rejection.

  Purpose: D-05 / D-06 / D-08 / D-09 / D-11 land server-side here. After this plan, an authed client streaming valid `position_update` messages becomes the authoritative source of its own x/y/vx/vy/facing/anim_state; the server stores and rebroadcasts via the existing Colyseus state-diff channel.
  Output: 2 modified source files (handlers + tick loop) + 1 modified game-logic file + 4 new test files (unit, two-client integ, override unit, v4 handshake integ).
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-PLAN.md
  @apps/server/src/onMessageHandlers.ts
  @apps/server/src/RebnoRoom.ts
  @packages/game-logic/src/step.ts
  @apps/server/test/d58c-set-facing.integ.test.ts
  @apps/server/test/event-driven-input.integ.test.ts
  @apps/server/test/protocol-v2-handshake.integ.test.ts

  <interfaces>
  <!-- All consumed from Plan 01. No codebase exploration required. -->

  From @rebno/protocol (Plan 01 deliverable):
    cPositionUpdateSchema    — zod object, .strict(), 9 fields
    cSetSpriteOverrideSchema — zod object, .strict(), { type, sprite_id }
    type CPositionUpdate, type CSetSpriteOverride
    PROTOCOL_VERSION = 4 (literal)
    PlayerState (Colyseus schema) gains:
      anim_state: number = 0x06
      sprite_override: string = 'none'
    Existing fields (pre-06.7): x, y, vx, vy, facing, last_input_seq, account_id, axis_x_held, axis_y_held — UNCHANGED

  Existing server scaffolding (DO NOT redefine — extend/reuse):
    onMessageHandlers.ts § lines 69-96 — `rateLimitOrDrop(ctx, client, msg_type, account_id)` token bucket
    onMessageHandlers.ts § lines 107-110 — `ctx.getAuth(client.sessionId)` resolves auth.account_id
    onMessageHandlers.ts § set_facing handler @ lines 264-301 — analog pattern (auth → rate-limit → zod → state write)
    RebnoRoom.ts § tickLoop @ lines 1274-1315 — current heldInputs/inputBuffer loop
    step.ts § player input application — block being trimmed (per RESEARCH §Pattern 2)
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: position_update + set_sprite_override handlers in onMessageHandlers.ts + handler unit tests</name>
    <read_first>
      - apps/server/src/onMessageHandlers.ts (FULL — set_facing handler @ lines 264-301 is the analog; rate-limit gate @ 69-96; auth resolution @ 107-110; msg_type rate-limit union @ line 72; import block @ 25-33)
      - apps/server/test/d58c-set-facing.integ.test.ts (entire file — test scaffolding: spawnServer, joinAs, vitest pattern)
      - packages/protocol/src/intents.ts (Plan 01 added cPositionUpdateSchema + cSetSpriteOverrideSchema — verify exports compile)
      - packages/protocol/src/state.ts (Plan 01 added anim_state + sprite_override PlayerState fields)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/server/src/onMessageHandlers.ts" §"Shared Patterns" (rate-limit, auth, zod .strict(), server-tagged identity, tag-comment)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 1" §"Pitfall 3" (rate-limit budget 35 tokens/sec / burst 60) §"Pattern 4" (stale-seq guard)
    </read_first>
    <behavior>
      Unit (apps/server/test/position-update.test.ts):
      - Handler with valid payload writes player.x, player.y, player.vx, player.vy, player.facing, player.anim_state, player.last_input_seq, leaves player.sprite_override unchanged
      - Handler rejects when no auth resolved (no PlayerState mutation)
      - Handler rejects when rate-limit returns false (rateLimitOrDrop dropped)
      - Handler rejects malformed payload (zod safeParse failure) — no mutation
      - Handler with stale seq (seq <= player.last_input_seq) returns early — no mutation, no last_input_seq advance
      - Handler with monotonic seq advances last_input_seq and writes new x/y
      - Handler with player not in state.players (e.g. mid-leave race) returns without throw

      Unit (apps/server/test/sprite-override.test.ts):
      - Handler with valid sprite_id='hexport_in' writes player.sprite_override = 'hexport_in'
      - Handler with sprite_id='none' clears the override
      - Handler rejects no-auth / invalid sprite_id / rate-limited cases analogously
    </behavior>
    <action>
      In `apps/server/src/onMessageHandlers.ts`:
      - Extend the `msg_type` union literal (line ~72) to include `'position_update'` and `'set_sprite_override'`.
      - Extend the import block (lines ~25-33) to add `cPositionUpdateSchema, cSetSpriteOverrideSchema` from `@rebno/protocol`.
      - Add the `position_update` handler immediately after the existing `set_facing` handler (the analog at lines 264-301). Implementation order per RESEARCH §Pattern 1 + PATTERNS.md "Shared Patterns":
        1. Resolve `auth = ctx.getAuth(client.sessionId)`; if missing, `log.warn` and return.
        2. `if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;`
        3. `const parsed = cPositionUpdateSchema.safeParse(raw);` — on `!parsed.success`, log warn with msg_type/sessionId/issue and return.
        4. Resolve the player via the same pattern as set_facing: `const player = ctx.room.state?.players?.get?.(client.sessionId);` typed via the local interface trick at lines 152-154; if missing, `log.warn(..., 'position_update_no_player_state')` and return.
        5. **Stale-seq guard:** `if (parsed.data.seq <= player.last_input_seq) return;` (do NOT advance last_input_seq).
        6. Write: `player.last_input_seq = parsed.data.seq; player.x = parsed.data.x; player.y = parsed.data.y; player.vx = parsed.data.vx; player.vy = parsed.data.vy; player.facing = parsed.data.facing; player.anim_state = parsed.data.anim_state;`
        7. Do NOT touch `player.sprite_override` — that's the other handler's domain.
      - Tag the handler block: `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` directly above `ctx.room.onMessage('position_update', ...)` per the precedent at lines 246-247.

      Add the `set_sprite_override` handler immediately after `position_update`. Implementation per RESEARCH §"set_sprite_override schema" + PATTERNS.md "sprite_override" pattern:
      - Same auth → rate-limit → zod → player-resolve flow.
      - On success: `player.sprite_override = parsed.data.sprite_id;` (the string enum value — 'none' is a legitimate clear).
      - No stale-seq guard (no seq field; it's an event-only intent per D-09).
      - Tag: `// [impl->REQ-SRV-03] [impl->REQ-CLI-04]`.

      Create `apps/server/test/position-update.test.ts`. Header tags `// [unit->REQ-SRV-03] [unit->REQ-CLI-04]`. Mirror the test scaffolding from `apps/server/test/d58c-set-facing.integ.test.ts` but UNIT-level (no spawnServer needed — call the handler directly with mocked `ctx`, `client`, `raw`, similar to mock-style server unit tests if any exist; otherwise structure as a thin integ that boots a single-client room). Cover the 7 unit behaviors enumerated above. The stale-seq case is critical (RESEARCH §Pattern 4) — assert that BOTH `player.x` is unchanged AND `player.last_input_seq` is unchanged when seq <= existing.

      Create `apps/server/test/sprite-override.test.ts`. Header tags `// [unit->REQ-SRV-03]`. Cover the override behaviors. Mirror the d58c-set-facing.integ.test.ts assertion shape: send the intent, await the next tick boundary or use `vi.waitFor` against `PlayerState.sprite_override`.
    </action>
    <verify>
      <automated>cd apps/server && pnpm exec vitest run test/position-update.test.ts test/sprite-override.test.ts</automated>
    </verify>
    <done>Both handlers compile against PlayerState's new fields; rate-limit msg_type extended; safeParse rejection paths and stale-seq guard verified; unit tests green.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "onMessage('position_update'" apps/server/src/onMessageHandlers.ts` matches exactly one line
      - Source assertion: `grep -n "onMessage('set_sprite_override'" apps/server/src/onMessageHandlers.ts` matches exactly one line
      - Source assertion: `grep -n "'position_update'" apps/server/src/onMessageHandlers.ts | grep -c "rateLimitOrDrop"` returns ≥ 1 (rate-limit gate is wired)
      - Source assertion: `grep -nE "if \(parsed\.data\.seq <= player\.last_input_seq\)" apps/server/src/onMessageHandlers.ts` matches the stale-seq guard
      - Source assertion: `grep -c "impl->REQ-SRV-03" apps/server/src/onMessageHandlers.ts` returns a value larger than its pre-06.7 count (the new handler tags landed) — record baseline with `git show HEAD:apps/server/src/onMessageHandlers.ts | grep -c "impl->REQ-SRV-03"` and assert strictly greater
      - Source assertion: rate-limit msg_type union (line ~72) contains both `'position_update'` and `'set_sprite_override'` — `grep -n "msg_type" apps/server/src/onMessageHandlers.ts | grep -c "position_update"` ≥ 1
      - Behavior assertion: `pnpm -C apps/server exec vitest run test/position-update.test.ts test/sprite-override.test.ts` exits 0 with ≥ 9 passing tests (7 + 2 floor)
      - Behavior assertion: existing handler tests do not regress — `pnpm -C apps/server exec vitest run test/d58c-set-facing.integ.test.ts` exits 0
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: RebnoRoom tickLoop empty-inputs + step.ts player pass-through + step regression test</name>
    <read_first>
      - apps/server/src/RebnoRoom.ts (tickLoop region @ lines 1274-1315 — the heldInputs / inputBuffer / step() loop is the surgical target)
      - packages/game-logic/src/step.ts (FULL — the player-input-application block is what becomes pass-through; the platform-advance loop is what's preserved)
      - packages/game-logic/test/step.test.ts (existing tests to preserve as a regression baseline + add a platform-advance assertion if missing)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/server/src/RebnoRoom.ts" + §"step.ts pass-through" (target pattern)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 2" §"Server tickLoop after the change" §"Server-side step() retention" §"Open Questions item 3" (heldInputs vestigial — leave for 06.9 cleanup)
    </read_first>
    <behavior>
      - step({players: { p1: {x:100, y:100, vx:0, vy:0, ...}, ... }, platforms: [...], ...}, new Map() /* empty inputs */, TICK_MS) returns a new state where p1.x === 100 and p1.y === 100 (UNCHANGED — player pass-through)
      - step({platforms: [{x:0, y:0, vx:1, vy:0, cycle_phase:0}]}, new Map(), TICK_MS=50) returns platforms[0].x = 0 + 1*50 = 50 (PLATFORMS UNCHANGED — REQ-SRV-14)
      - step({platforms: [{x:0, y:0, vx:1, vy:0, cycle_phase:0}]}, new Map(), TICK_MS=50) advances cycle_phase by 50
      - tickLoop in RebnoRoom invokes step() with an empty Map (not a heldInputs-derived map)
      - heldInputs writes from cInputSchema handler are NOT touched (vestigial per Open Question 3)
    </behavior>
    <action>
      In `apps/server/src/RebnoRoom.ts` tickLoop (lines 1274-1315):
      - DELETE the inner for-loop that copies heldInputs into the `inputs` Map (lines 1289-1300 region per PATTERNS.md "Current player-input application pattern (TO REMOVE for self-player)").
      - REPLACE the `step(this.toWorldState(), inputs, TICK_MS)` call with `step(this.toWorldState(), new Map(), TICK_MS)` and a comment block citing REQ-SRV-14 + D-01:
        `// 06.7 D-01/D-05 — Player position is now written by the position_update`
        `// handler (onMessageHandlers.ts). step() runs ONLY for platforms`
        `// (REQ-SRV-14). Empty inputs Map: with the step.ts pass-through change,`
        `// players are round-tripped unchanged.`
      - Do NOT delete the `inputBuffer` writes / `heldInputs` writes — left vestigial per Open Question 3 / 06.9 cleanup. Add a TODO comment near the heldInputs declaration: `// TODO(06.9): vestigial post-06.7 — see CONTEXT D-01 / Open Question 3.`

      In `packages/game-logic/src/step.ts`:
      - In the player-input-application block (the loop that reads `inputs.get(account_id)` and applies axes to player vx/vy), GATE the per-player input application on `inputs.size > 0`. When `inputs` is empty (the new server case from 06.7), the players Map round-trips with whatever vx/vy the handler last wrote. Equivalent acceptable patterns: (a) early-skip the input-application loop when `inputs.size === 0`; (b) keep the loop but make each iteration a no-op when `inputs.get(account_id)` returns undefined.
      - Tag the changed region: `// [impl->REQ-SRV-14] — platforms still advance; players are`
        `// pass-through when inputs is empty (06.7 client-trust model).`
      - DO NOT remove the input-application code path entirely. Client-side `predictor.ts` STILL calls `step()` with non-empty inputs for local prediction (RESEARCH §"Pattern 2" "Important nuance"). Both call sites must keep working — server passes empty, client passes its keyboard-derived axes.

      Update `packages/game-logic/test/step.test.ts`:
      - Add (if not already present) an `it` case asserting platform advance: build a state with one platform { x:0, y:0, vx:1, vy:0, cycle_phase:0 } and empty players + empty inputs; call `step(state, new Map(), 50)`; assert `result.platforms[0].x === 50` and `result.platforms[0].cycle_phase === 50`.
      - Add an `it` case asserting player pass-through: build a state with one player at (100, 100, vx:0, vy:0) and empty inputs; call `step(state, new Map(), 50)`; assert `result.players[0].x === 100 && result.players[0].y === 100`.
      - Tag the new cases `// [unit->REQ-SRV-14] [unit->REQ-SRV-03]`.

      KEY NOTE: existing step.test.ts cases that pass non-empty inputs must STILL pass (client-side prediction path is unchanged). Run the full file at the end to confirm.
    </action>
    <verify>
      <automated>cd packages/game-logic && pnpm exec vitest run test/step.test.ts && cd ../../apps/server && pnpm exec tsc --noEmit</automated>
    </verify>
    <done>Server tickLoop calls step with empty Map; step.ts gates input application on inputs.size; all existing step tests still pass; new pass-through + platform-advance regression cases pass; server typecheck clean.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "step\(this\.toWorldState\(\), new Map\(\)" apps/server/src/RebnoRoom.ts` matches exactly one line inside tickLoop
      - Source assertion: tickLoop NO LONGER copies `heldInputs` into the per-tick inputs Map — `grep -nE "for \(const \[account_id, held\] of this\.heldInputs\)" apps/server/src/RebnoRoom.ts` returns zero matches (the copy loop is removed)
      - Source assertion: `grep -nE "inputs\.size === 0|inputs\.size > 0" packages/game-logic/src/step.ts` matches at least one line (the gate is wired)
      - Source assertion: `grep -n "REQ-SRV-14" packages/game-logic/src/step.ts` count increases vs. pre-06.7 baseline (new tag landed)
      - Source assertion: `grep -n "TODO(06.9)" apps/server/src/RebnoRoom.ts` matches the heldInputs vestigial note
      - Behavior assertion: `pnpm -C packages/game-logic exec vitest run test/step.test.ts` exits 0 — all existing tests green PLUS the two new regression cases (pass-through + platform-advance) pass
      - Build assertion: `pnpm -C apps/server exec tsc --noEmit` exits 0
      - Regression assertion: `pnpm -C packages/game-logic exec vitest run` (full game-logic suite) exits 0
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Two-client position_update integ + protocol-v4-handshake integ</name>
    <read_first>
      - apps/server/test/event-driven-input.integ.test.ts (FULL — analog for two-client integ; joinClient helper, positionOf accessor)
      - apps/server/test/protocol-v2-handshake.integ.test.ts (FULL — analog for handshake-rejection integ; close-code 4400 / PROTOCOL_VERSION_MISMATCH assertion; joinOrCreate `protocol_version` option)
      - apps/server/test/d58c-set-facing.integ.test.ts (FULL — two-client setup pattern reference)
      - apps/server/test/test-utils.* (spawnServer / joinAs helpers — used by the analog tests)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"position-update.integ.test.ts" §"protocol-v4-handshake.integ.test.ts"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Validation Architecture" + §"Pitfall 4"
      - packages/protocol/src/version.ts (Plan 01 set PROTOCOL_VERSION=4)
    </read_first>
    <behavior>
      apps/server/test/position-update.integ.test.ts:
      - Client A joins, client B joins; both authed
      - Client A sends position_update {x:500, y:400, vx:5, vy:0, facing:'R', anim_state:0x08 /* RUN_R */, seq:1, monotonic_at_ms:1000}
      - Within the next setPatchRate cycle (~50ms; use vi.waitFor with 500ms budget), client B's view of PlayerState[A].x equals 500 and y equals 400 and anim_state equals 0x08 and facing equals 'R'
      - Client A sends a SECOND position_update with seq=1 (duplicate); server discards (player.last_input_seq stays at 1, no extra state churn observable)
      - Client A sends position_update with seq=2 and new coords; B observes them
      - Client A sends set_sprite_override {sprite_id:'hexport_in'}; B observes PlayerState[A].sprite_override === 'hexport_in'

      apps/server/test/protocol-v4-handshake.integ.test.ts:
      - `expect(PROTOCOL_VERSION).toBe(4);` sanity
      - A client joining with `joinOrCreate('rebno', { protocol_version: 3, session_token: 'dev-bypass' })` receives the close-code 4400 / PROTOCOL_VERSION_MISMATCH error (mirroring protocol-v2-handshake.integ.test.ts)
      - A client joining with `protocol_version: 4` succeeds (no close, joined state observable)
    </behavior>
    <action>
      Create `apps/server/test/position-update.integ.test.ts`:
      - Header tags `// [int->REQ-SRV-03] [int->REQ-CLI-04] [int->REQ-CLI-08]`.
      - Import vitest + spawnServer + joinAs/joinClient helpers from `./test-utils.js` per the analog files.
      - `describe('position_update integration (06.7)', () => { ... })`.
      - `beforeAll` spawns server; `afterAll` shuts down. Per-test `beforeEach` spawns fresh room if helper supports it; otherwise scope to one shared room.
      - Use the `positionOf(roomB, account_id)` accessor shape from event-driven-input.integ.test.ts:55-63 to read remote-player PlayerState from client B's view.
      - Cover the 5 integ behaviors enumerated above. Use `vi.waitFor` with a 500ms budget for the post-broadcast read (Colyseus setPatchRate is 50ms — 10x margin is safe in CI).
      - For the duplicate-seq case: after sending the second seq=1 update, wait one full patch cycle, then assert `roomB.state.players.get(aSessionId).last_input_seq === 1` (not 0, not 2 — discarded but the first one is still tracked).

      Create `apps/server/test/protocol-v4-handshake.integ.test.ts`:
      - Header tags `// [int->REQ-SRV-03]`.
      - Copy the structure of `protocol-v2-handshake.integ.test.ts` lines 1-68 verbatim, substituting:
        - 2 → 3 (the old version being rejected) — wait, re-read: PROTOCOL_VERSION-2 test rejected version 1. The new v4 test should reject version 3 specifically (the immediate predecessor) and accept version 4. Read the v2 analog to confirm the exact pattern (the v2 analog likely rejects v1 and accepts v2).
        - Asserts `expect(PROTOCOL_VERSION).toBe(4);` at the top.
        - Reject path: `protocol_version: 3` → expect close 4400 with `PROTOCOL_VERSION_MISMATCH`.
        - Accept path: `protocol_version: 4` → expect successful join.
      - Use `client.joinOrCreate('rebno', { protocol_version: <N>, session_token: 'dev-bypass' })` exactly as the analog does at line ~38.
    </action>
    <verify>
      <automated>cd apps/server && pnpm exec vitest run test/position-update.integ.test.ts test/protocol-v4-handshake.integ.test.ts</automated>
    </verify>
    <done>Two-client integ proves the position_update + set_sprite_override roundtrip and the stale-seq guard end-to-end; v3 client rejection is enforced; v4 client still accepted.</done>
    <acceptance_criteria>
      - Source assertion: file `apps/server/test/position-update.integ.test.ts` exists and contains at least one `describe(` block and 5 `it(` blocks
      - Source assertion: file `apps/server/test/protocol-v4-handshake.integ.test.ts` exists; `grep -n "PROTOCOL_VERSION_MISMATCH" apps/server/test/protocol-v4-handshake.integ.test.ts` matches at least once
      - Source assertion: `grep -nE "protocol_version: ?3" apps/server/test/protocol-v4-handshake.integ.test.ts` matches the reject case
      - Source assertion: `grep -nE "protocol_version: ?4" apps/server/test/protocol-v4-handshake.integ.test.ts` matches the accept case
      - Behavior assertion: `pnpm -C apps/server exec vitest run test/position-update.integ.test.ts test/protocol-v4-handshake.integ.test.ts` exits 0 with all listed cases passing
      - Regression assertion: `pnpm -C apps/server exec vitest run` (full server suite) exits 0 — no other tests broken by the rate-limit / state-write changes from Task 1 or the tickLoop change from Task 2
      - Tag assertion: `grep -c "int->REQ-CLI-08" apps/server/test/position-update.integ.test.ts` ≥ 1
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server `onMessage('position_update')` | zod-validate + rate-limit + server-tagged identity gate the only ingress for client-claimed position |
  | client → server `onMessage('set_sprite_override')` | Same gate; closed enum for sprite_id |
  | server → client `PlayerState` state-diff broadcast | Standard Colyseus channel; no new untrusted data crosses |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-01b | Tampering | client-fabricated `{x, y, vx, vy}` carrying impossible values | **accept (semantic) + mitigate (envelope)** | Per D-11, server semantic legality (speed cap, walkable-grid) is OUT OF SCOPE — deferred to candidate Phase 06.8. **In 06.7 the operator pool is trusted and 50 CCU is the cap.** Envelope-only defense: int16 bounds reject -32769+ values at zod parse; rate-limit token bucket (35 tokens/sec / burst 60) caps tick-rate abuse; stale-seq guard rejects replays. Threat is documented and consciously not mitigated semantically. |
  | T-06.7-02 | Tampering | `set_sprite_override` injection (e.g. unknown sprite_id, malformed type) | mitigate | `cSetSpriteOverrideSchema` is a tight z.enum closed set + `.strict()`. Rejection at handler `safeParse`. No state mutation possible from a malformed payload. |
  | T-06.7-03 | DoS | `position_update` flood from a runaway / hostile client (1000+ Hz) | mitigate | Token bucket per `(account_id, 'position_update')` with refill 35 tokens/sec, burst 60. RESEARCH §"Pitfall 3". `position_update` is added to the existing rate-limit msg_type union; cheap-path-before-zod pattern from onMessageHandlers:69-96 reused. |
  | T-06.7-04 | Spoofing | client sends position_update claiming `{ account_id: 'victim' }` field | mitigate | `.strict()` on cPositionUpdateSchema rejects any extra field. Server-tagged identity from `ctx.getAuth(client.sessionId).account_id` per existing pattern; wire-side identity is never consulted. PlayerState key is `client.sessionId`, never wire-supplied. |
  | T-06.7-06 | Tampering | TCP-reorder replay of an older position_update bumps stale state | mitigate | `if (parsed.data.seq <= player.last_input_seq) return;` stale-seq guard. RESEARCH §"Pattern 4". Cheap; defends against the rare TCP-reorder-after-reconnect window. |
  | T-06.7-07 | Integrity (regression) | A future refactor accidentally re-introduces server-side position derivation, double-writing player.x | mitigate | Player pass-through in step.ts is documented + tagged `// [impl->REQ-SRV-14]`. The platform-advance regression test in Task 2 + the pass-through regression test guard against revert. Plan 03's reconciler-no-op test guards the client side. |

  **Doctrine acknowledgement (D-11):** Server-side anti-cheat envelope is OUT OF SCOPE per CONTEXT D-11. This plan ships ZERO semantic position-legality checks. The trade is acceptable on a pre-launch private staging with ≤50 trusted operators; once cheating becomes observable on real-user traffic OR before public launch, candidate Phase 06.8 ships speed-cap + walkable-grid sanity + fall-reset. This plan's threat register documents that acceptance explicitly so a future verifier reading the post-mortem understands the deferral chain.

  **CLAUDE.md Hard Rule 1 cross-reference:** Plan 04 lands the doctrine edit (D-13) that codifies the trust split. This plan implements the substrate.
  </threat_model>

  <verification>
  - `pnpm -C apps/server test` green for the full server unit suite (position-update.test.ts + sprite-override.test.ts + existing tests)
  - `pnpm -C apps/server test:integration` green for the full server integ suite (position-update.integ.test.ts + protocol-v4-handshake.integ.test.ts + existing integs)
  - `pnpm -C packages/game-logic test` green (step.test.ts platform + pass-through cases + existing cases)
  - `pnpm -C apps/server exec tsc --noEmit` clean
  - `pnpm trace:check` does not regress on REQ-SRV-03 / REQ-SRV-14 / REQ-CLI-08
  - Manual sanity: `grep -nE "onMessage\('position_update'" apps/server/src/onMessageHandlers.ts | wc -l` returns 1 (no accidental duplicate handlers)
  </verification>

  <success_criteria>
  - Server accepts authed position_update + set_sprite_override and writes PlayerState through Colyseus state-diff broadcast — verified by two-client integ
  - Server tickLoop step() is platforms-only — players round-trip; existing platform tests still green
  - v3 client rejected at handshake; v4 client accepted
  - Stale-seq guard verified end-to-end
  - No existing server / game-logic tests broken
  - Plan 03 (client) is unblocked: client can `room.send('position_update', payload)` and observe its own state on the server, plus see remote players' position via the existing PlayerState state-diff channel
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-02-SUMMARY.md`. Note: the heldInputs vestigial cleanup is intentionally deferred to 06.9 — document this with the TODO line-number reference so the future cleanup phase has a concrete starting point. Also document the rate-limit budget choice (35/sec burst 60) so operator dashboards know the dropped-rate baseline.
  </output>

  ---
  #### 06.7-03-PLAN.md
  ---
  phase: 06.7
  plan: 03
  type: execute
  wave: 2
  depends_on:
    - 06.7-01
  files_modified:
    - apps/client/src/prediction/position-dispatcher.ts
    - apps/client/src/prediction/reconciler.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/net/colyseus-client.ts
    - apps/client/src/__test__/position-dispatcher.test.ts
    - apps/client/src/__test__/reconciler.test.ts
  autonomous: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
  must_haves:
    truths:
      - "Client emits a c2s.position_update per sim tick (~30 Hz) populated from PredictionEngine.getLocalState + SpriteStateMachine output"
      - "Client emits a c2s.position_update per sim tick EVEN WHEN THE PLAYER IS IDLE (D-06 steady cadence — getLocalState() returning {vx:0, vy:0} still produces a valid payload that passes cPositionUpdateSchema.safeParse)"
      - "ReconcileEngine.onServerSnapshot is a NO-OP for the self-player (returns early when called from the self-snapshot path) — sprite.setPosition and sprite.tweenTo are never called for self from this code path"
      - "ReconcileEngine.onServerSnapshot for remote players preserves existing lerp/threshold behavior (remote-path symmetry retained per the W7 lock)"
      - "GameScene invokes PositionDispatcher.sendTick on the local sim-tick boundary (the 30 Hz scheduler already in place at apps/client/src/scenes/GameScene.ts:1166-1206 — the `while (this.simTickAccumulator >= SIM_TICK_MS)` loop inside `override update(_time, dt)`)"
      - "GameScene's call site that previously invoked reconcile.onServerSnapshot for self passes the new isSelfPlayer=true flag so the call body returns early (D-04 positive guard)"
      - "globalThis.__rebno.lastReconcile* writes for the self-snapshot path are gone (no diagnostic-ring-buffer entries written when isSelfPlayer=true)"
      - "D-57b and D-58b anti-revert tests are retired (the reconciler-snap behavior they guarded is now intentionally absent); replaced by the positive guard test"
      - "PROTOCOL_VERSION sent on handshake = 4"
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        provides: "PositionDispatcher class — sendTick(monotonic_at_ms) builds + sends c2s.position_update"
        contains: "PositionDispatcher"
      - path: "apps/client/src/prediction/reconciler.ts"
        provides: "onServerSnapshot with isSelfPlayer self-noop branch + header doc + globalThis ringbuffer removal for self"
        contains: "isSelfPlayer"
      - path: "apps/client/src/scenes/GameScene.ts"
        provides: "PositionDispatcher instantiation + per-tick send; reconciler self-snapshot call passes isSelfPlayer=true"
        contains: "PositionDispatcher"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        provides: "Unit tests — sendTick payload shape, seq monotonicity, packAnimState wiring, D-06 idle cadence guard"
        contains: "sendTick"
      - path: "apps/client/src/__test__/reconciler.test.ts"
        provides: "D-04 positive-guard it case — self-snapshot is no-op + retiring D-57b/D-58b cases + remote-symmetry preserved tests use isSelfPlayer=false"
        contains: "D-04"
    key_links:
      - from: "apps/client/src/scenes/GameScene.ts (per-tick update loop)"
        to: "PositionDispatcher.sendTick(monotonic_at_ms)"
        via: "invoked from the same scheduler that calls predictTick — GameScene.ts:1166-1206 (inside `override update(_time, dt)`), immediately after `this.prediction.predictTick(...)` at line 1168"
        pattern: "positionDispatcher\\.sendTick|posDispatcher\\.sendTick"
      - from: "apps/client/src/prediction/reconciler.ts onServerSnapshot"
        to: "early-return when isSelfPlayer"
        via: "isSelfPlayer flag parameter (or local-id comparison)"
        pattern: "if \\(isSelfPlayer\\)"
      - from: "apps/client/src/scenes/GameScene.ts:761 (existing reconcile call site)"
        to: "onServerSnapshot(snap, isSelfPlayer=true)"
        via: "pass the new flag (or stop calling for self entirely)"
        pattern: "reconcile\\?\\.onServerSnapshot"
  ---

  <objective>
  Wire the client side of the client-trust flip: introduce `PositionDispatcher` that ships the new c2s.position_update every sim tick (including idle ticks per D-06); make `ReconcileEngine.onServerSnapshot` a no-op for the self-player (positive D-04 guard) while PRESERVING the remote-player lerp/threshold path for signature symmetry (W7 lock); remove the self-path diagnostic-ring-buffer writes; update GameScene to instantiate the dispatcher, schedule its `sendTick` inside the existing 30 Hz `override update(_time, dt)` accumulator loop at lines 1166-1206, and pass `isSelfPlayer=true` when invoking the reconciler for self; bump handshake `protocol_version` to 4.

  Purpose: D-04 (D-57b/D-58b retired + positive guard) + D-05/D-06/D-07/D-08/D-12 land client-side here. After this plan, self-player position is OWNED by the client; the round-trip diagonal-stop drift bug from operator UAT 2026-05-16 cannot recur (no snap path remains).
  Output: 1 NEW dispatcher file + reconciler self-noop edits (remote path preserved) + GameScene wiring + colyseus-client handshake bump + 2 test files (1 new dispatcher unit + reconciler test additions/retirements). Plan touches 6 files with 5 edit-regions in GameScene.ts (import + field declaration + instantiation + per-tick invocation + reconnect setRoom + isSelfPlayer pass-through); kept co-located in a single task per W6 acceptance.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-PLAN.md
  @apps/client/src/prediction/predictor.ts
  @apps/client/src/prediction/reconciler.ts
  @apps/client/src/prediction/input-dispatcher.ts
  @apps/client/src/net/colyseus-client.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/__test__/reconciler.test.ts
  @apps/client/src/__test__/input-dispatcher.test.ts

  <interfaces>
  <!-- From Plan 01 (@rebno/protocol) — already importable: -->
    packAnimState(facing, isRunning): number
    FACING_OCTANT: Record<Direction, number>
    type CPositionUpdate, type Direction (or imported from intents.ts via re-export)
    cPositionUpdateSchema (for test fixtures, optional)
    PROTOCOL_VERSION (literal 4)

  <!-- From existing client code (KEEP — extend, do not redefine): -->
    PredictionEngine in apps/client/src/prediction/predictor.ts
      - getLocalState(): { x: number, y: number, vx: number, vy: number }
        (or equivalent — if the exact name differs, executor confirms via Read first; the spec is "current local sim state for self")
      - predictTick(layout, axes) — runs locally each frame (UNCHANGED in 06.7)
    SpriteStateMachine in apps/client/src/render/SpriteStateMachine.ts
      - exposes current pose / running state (consumed by getIsRunning callback)
    InputDispatcher in apps/client/src/prediction/input-dispatcher.ts
      - sendInput(axes) — for chat-focus pauseMovement contract (KEPT vestigially; not the focus of this plan)
      - HEARTBEAT_INTERVAL_MS = 15_000 (KEPT — used in chat-focus / menu state)
    ReconcileEngine in apps/client/src/prediction/reconciler.ts
      - onServerSnapshot(snap, isSelfPlayer) — body becomes self-noop branch; remote-path lerp/threshold preserved per W7 lock
    GameScene in apps/client/src/scenes/GameScene.ts
      - line 79: import { ReconcileEngine }
      - line 131: this.reconcile field declaration
      - line 709: this.reconcile = new ReconcileEngine(...)
      - line 761: this.reconcile?.onServerSnapshot({ ... }) — the self-call site
      - line 1152: `override update(_time: number, dt: number): void` — the per-frame entry
      - lines 1166-1206: `while (this.simTickAccumulator >= SIM_TICK_MS)` — the 30 Hz sim-tick loop; predictTick() runs at 1168; PositionDispatcher.sendTick() is wired immediately after the predictTick call inside this same loop body (one tick = one position_update, including idle ticks per D-06)

  <!-- Wire shape (from Plan 01): -->
    c2s.position_update payload = {
      type: 'position_update',
      x, y, vx, vy: int16-rounded
      facing: 'D'|'DR'|...
      anim_state: u8 packed via packAnimState
      seq: u32 monotonic per dispatcher
      monotonic_at_ms: u32
    }
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: PositionDispatcher class + unit tests (incl. D-06 idle-cadence guard)</name>
    <read_first>
      - apps/client/src/prediction/input-dispatcher.ts (FULL — `sendInput` @ lines 256-286 is the analog; the class shape with `_room`/`setRoom` @ lines 76-118 is the lifecycle precedent for Colyseus reconnect; HEARTBEAT_INTERVAL_MS @ line 26)
      - apps/client/src/prediction/predictor.ts (the PredictionEngine API — confirm the exact name of the "get current local sim state" method; predictTick + getLocalState or equivalent)
      - apps/client/src/render/SpriteStateMachine.ts (the SpriteStateMachine API — confirm how to query current facing + isRunning; the position_update needs both)
      - apps/client/src/__test__/input-dispatcher.test.ts (FULL — `makeMockRoom`, `makeMockPrediction`, `vi.useFakeTimers` pattern; this is the test scaffolding to mirror)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/prediction/position-dispatcher.ts (NEW)"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Position dispatcher (client, every-tick send)" (full code spec)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-VALIDATION.md (row "D-06 idle cadence guard — PositionDispatcher.sendTick emits valid payload when getLocalState returns {vx:0, vy:0}")
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-06 (steady 30 Hz cadence; idle player still emits position_update)
      - packages/protocol/src/anim-state.ts (Plan 01 — packAnimState + FACING_OCTANT exports)
    </read_first>
    <behavior>
      - new PositionDispatcher(room, prediction, getFacing, getIsRunning); calling sendTick(1000) invokes room.send('position_update', payload) exactly once
      - The payload's `type` field equals 'position_update'
      - The payload's x, y, vx, vy are Math.round() of the prediction.getLocalState() values (i.e. quantized to int16)
      - The payload's `facing` equals what `getFacing()` returns
      - The payload's `anim_state` equals `packAnimState(getFacing(), getIsRunning())`
      - The payload's `seq` is monotonically increasing across multiple sendTick calls (1, 2, 3, ...)
      - The payload's `monotonic_at_ms` equals the value passed to sendTick
      - setRoom(newRoom) routes subsequent sendTick calls to newRoom (Colyseus reconnect lifecycle); the old room.send is NOT called after setRoom
      - cPositionUpdateSchema.safeParse(payload).success === true (envelope correctness)
      - **D-06 idle-cadence guard (REQ-CLI-04 — addresses checker W3):** when `prediction.getLocalState()` returns `{ x: 500, y: 400, vx: 0, vy: 0 }` (the player has been idle for N frames), sendTick(monotonic_at_ms) STILL invokes `room.send('position_update', payload)` exactly once and the emitted payload passes `cPositionUpdateSchema.safeParse(...).success === true`. The dispatcher does NOT skip / coalesce / suppress idle ticks. Asserts (a) `room.send` call count == 1 per sendTick invocation regardless of vx/vy = 0, (b) `payload.vx === 0`, `payload.vy === 0`, and (c) the safeParse success — proving the server's zod gate will accept the idle frame.
    </behavior>
    <action>
      Create `apps/client/src/prediction/position-dispatcher.ts`. Tag the module header `// [impl->REQ-CLI-04] [impl->REQ-CLI-08]`. Implement per the spec in RESEARCH §"Position dispatcher (client, every-tick send)" and PATTERNS.md §"Target shape (NEW)":
      - Class `PositionDispatcher` with constructor taking `(room: Pick<Room, 'send'>, prediction: PredictionEngine, getFacing: () => Direction, getIsRunning: () => boolean)`.
      - Mutable `_room` + `setRoom(room)` setter mirroring InputDispatcher lines 76-118 for Colyseus reconnect lifecycle.
      - `private nextSeq = 0;`
      - Public `sendTick(monotonic_at_ms: number): void` that builds the payload from `prediction.getLocalState()` (confirm exact method name during the Read step; if the API exposes the local sim state under a different name, adapt the action — the contract is "current local sim x/y/vx/vy for self"), rounds each numeric field with Math.round, queries facing + running via the injected callbacks, packs anim_state, increments seq, and calls `_room.send('position_update', { type, x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms })`. **Per D-06 the method does NOT gate on vx/vy = 0** — idle player still emits a payload every tick (steady 30 Hz cadence).
      - Imports: `import { packAnimState, type Direction } from '@rebno/protocol';` + type Room from '@colyseus/sdk' + the predictor type.

      Create `apps/client/src/__test__/position-dispatcher.test.ts`. Tag header `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Mirror `apps/client/src/__test__/input-dispatcher.test.ts` lines 1-80:
      - `makeMockRoom()` returns `{ send: vi.fn() }`.
      - `makeMockPrediction()` returns an object exposing whatever method PositionDispatcher consumes (`getLocalState()` returns `{ x: 100.7, y: 200.2, vx: 1.1, vy: -2.4 }` or similar — non-integer to verify Math.round). Also export a helper `makeIdlePrediction()` returning `{ getLocalState: () => ({ x: 500, y: 400, vx: 0, vy: 0 }) }` for the D-06 case below.
      - Use `vi.useFakeTimers()` setup if any timer interaction is needed (probably none — sendTick is synchronous).
      - Test cases mirror the 10 behaviors above (the original 9 + the new D-06 idle-cadence guard). Assertion shape: `room.send.mock.calls[0]![1]` is the payload object; compare field-by-field with `toEqual` / `toMatchObject`.
      - Round-trip case: `expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true)` — proves the dispatcher emits a payload that the server's zod gate will accept.
      - **D-06 idle-cadence test (NEW — addresses W3):**
        ```
        it('D-06 06.7 — sendTick emits a valid payload when player is idle (vx=0, vy=0)', () => {
          const room = makeMockRoom();
          const prediction = makeIdlePrediction(); // getLocalState() → { x: 500, y: 400, vx: 0, vy: 0 }
          const dispatcher = new PositionDispatcher(room, prediction, () => 'D', () => false);
          dispatcher.sendTick(1000);
          expect(room.send).toHaveBeenCalledTimes(1);
          const [, payload] = room.send.mock.calls[0]!;
          expect(payload.vx).toBe(0);
          expect(payload.vy).toBe(0);
          expect(payload.x).toBe(500);
          expect(payload.y).toBe(400);
          expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true);
        });
        ```

      Note: this task does NOT wire the dispatcher into GameScene — that's Task 3. The dispatcher must be unit-testable in isolation.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>PositionDispatcher class exists, is exportable, builds correct payloads that pass cPositionUpdateSchema.safeParse; D-06 idle-cadence guard test green; unit tests green.</done>
    <acceptance_criteria>
      - Source assertion: file `apps/client/src/prediction/position-dispatcher.ts` exists; `grep -n "export class PositionDispatcher" apps/client/src/prediction/position-dispatcher.ts` matches exactly one line
      - Source assertion: `grep -n "packAnimState" apps/client/src/prediction/position-dispatcher.ts` matches at least one line (anim_state wiring)
      - Source assertion: `grep -n "Math.round" apps/client/src/prediction/position-dispatcher.ts` matches at least 4 lines (x, y, vx, vy rounding per D-07)
      - Source assertion: `grep -n "setRoom" apps/client/src/prediction/position-dispatcher.ts` matches at least one line (reconnect lifecycle)
      - Source assertion: file `apps/client/src/__test__/position-dispatcher.test.ts` exists with at least 9 `it(` blocks (8 original + the new D-06 idle-cadence case)
      - Source assertion: `grep -nE "D-06 06\.7|D-06 idle|idle cadence" apps/client/src/__test__/position-dispatcher.test.ts` matches at least one line (the D-06 idle test is present)
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts` exits 0; both the cPositionUpdateSchema.safeParse-success case AND the D-06 idle-cadence case pass
      - Tag assertion: `grep -c "impl->REQ-CLI-04" apps/client/src/prediction/position-dispatcher.ts` ≥ 1
      - Tag assertion: `grep -c "unit->REQ-CLI-04" apps/client/src/__test__/position-dispatcher.test.ts` ≥ 1
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: ReconcileEngine self-noop + reconciler test (D-04 positive guard, retire D-57b/D-58b, PRESERVE remote symmetry)</name>
    <read_first>
      - apps/client/src/prediction/reconciler.ts (FULL — onServerSnapshot @ 52-122 is the surgical target; the globalThis.__rebno.lastReconcile* writes @ 73-92 + 100-119 are what's being removed for the self-path)
      - apps/client/src/__test__/reconciler.test.ts (FULL — the existing 5+ it cases including the D-57b/D-58b anti-revert ones; identify which ones to retire vs. keep with isSelfPlayer=false)
      - apps/client/src/scenes/GameScene.ts (lines 700-790 — the existing call site at line 761 that invokes reconcile.onServerSnapshot for self; that signature changes to accept isSelfPlayer)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/prediction/reconciler.ts"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 3" §"Pitfall 1" §"Pitfall 6"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-04 (the retire-then-replace acceptance criterion)
    </read_first>
    <behavior>
      Reconciler unit (apps/client/src/__test__/reconciler.test.ts):
      - D-04 positive guard: `eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true)` produces ZERO calls to `m.setPosition` and ZERO calls to `m.tweenTo` (mockAdapter spies)
      - D-04 corollary: even with extreme divergence (>22 px threshold), self-path is no-op
      - D-04 corollary: the globalThis ringbuffer for self is NOT written (`globalThis.__rebno?.lastReconcileSelfX` etc. remain undefined or at their pre-call value)
      - Existing D-57b and D-58b anti-revert tests are REMOVED from the file (their preconditions no longer make sense — they asserted snap-on-divergence behavior that is intentionally absent)
      - **Remote-symmetry preserved (W7 lock):** any existing lerp/threshold cases for the REMOTE-player path are RETAINED and updated to pass `isSelfPlayer=false` EXPLICITLY. The remote-player branch of `onServerSnapshot` is NOT collapsed to a no-op; the existing lerp/threshold sprite-write behavior is preserved end-to-end. Specifically: a remote case calling `onServerSnapshot(snap, /* isSelfPlayer */ false)` with a >22 px divergence MUST still produce a `m.setPosition` (or `m.tweenTo`) call, matching the pre-06.7 remote-reconciliation behavior. A remote case below threshold continues to behave as it did pre-06.7.

      Reconciler source (apps/client/src/prediction/reconciler.ts):
      - onServerSnapshot signature accepts `isSelfPlayer: boolean` as a second parameter (or new option object — pick the cleanest fit for the existing callers, which is GameScene only)
      - When `isSelfPlayer === true`, the function returns early immediately after the param decode; NO sprite writes, NO tween, NO globalThis writes
      - When `isSelfPlayer === false` (remote-player path — **PRESERVED per W7 lock**) the existing lerp/threshold + sprite-write behavior stays intact. Do NOT collapse to a no-op. Do NOT delete the divergence-threshold sprite write. Remote-player rendering is OUT OF SCOPE for 06.7 changes — only the self-player branch flips.
    </behavior>
    <action>
      In `apps/client/src/prediction/reconciler.ts`:
      - Update the file header (lines 1-16) per PATTERNS.md §"Header-comment update pattern":
        ```
        // [impl->REQ-CLI-04]
        // 06.7 D-04 — ReconcileEngine.onServerSnapshot is a NO-OP for the self-player.
        // Self-player position is owned by the client (predictor.ts + PositionDispatcher);
        // snapshot-driven snap/lerp would re-introduce the diagonal-stop ~10 px drift
        // (operator UAT 2026-05-16). The REMOTE-player branch is PRESERVED with its
        // existing lerp/threshold behavior — only the self-player branch flips
        // (W7 lock: remote symmetry intentionally retained; remote rendering
        // continues to flow through onServerSnapshot's remote-path code).
        ```
      - Change the signature of `onServerSnapshot(snap)` to `onServerSnapshot(snap, isSelfPlayer: boolean)`.
      - At the very top of the method body: `if (isSelfPlayer) { return; }` — comment `// [impl->REQ-CLI-04] D-04 positive guard — see file header.`
      - REMOVE the globalThis.__rebno.lastReconcile* writes that occur on the self-path (per PATTERNS.md "Drop the globalThis ring-buffer write for the self-player branch (lines 73-92 + 100-119)"). The diagnostic ring-buffer is no longer useful for self; it remains in the file's history for posterity but the runtime no longer writes when isSelfPlayer=true.
      - **PRESERVE the remote-path lerp/threshold code below the self-guard return.** Do NOT delete `DIVERGENCE_THRESHOLD_PX` constant. Do NOT delete the sprite.setPosition/tweenTo call paths in the remote branch. Remove the `@deprecated` JSDoc note that the previous plan-revision draft suggested — per W7 the constant IS still live for remote reconciliation.

      In `apps/client/src/scenes/GameScene.ts`:
      - Update the existing call site at line ~761 `this.reconcile?.onServerSnapshot({ ... })` to pass `isSelfPlayer: true` as the new second argument. (The call site IS the self-snapshot path — verify by re-reading the surrounding context at lines 740-780; the snapshot is built from the local player's PlayerState row, so isSelfPlayer is unconditionally true here.) Prefer passing `isSelfPlayer=true` to preserve the call structure and avoid touching the surrounding fired-callback logic at line 776.
      - Add `scene.tweens.killTweensOf(localSprite)` defensively in the predictor-write path per RESEARCH §"Pitfall 6" (Phaser tween fighting predictor) — only if a tween reference still exists in the local-sprite write path. If no in-flight tween path remains (i.e. no GameScene code calls `tweenTo` against localSprite outside of the now-noop reconciler), skip this defensive call. Document the decision in a code comment.

      In `apps/client/src/__test__/reconciler.test.ts`:
      - Add the D-04 positive-guard `it` block per PATTERNS.md §"reconciler.test.ts (ADD case)":
        ```
        it('D-04 06.7 — onServerSnapshot is a NO-OP for self-player (no setPosition, no tweenTo)', () => {
          const m = mockAdapter(100, 100);
          const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
          eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true);
          expect(m.setPosition).not.toHaveBeenCalled();
          expect(m.tweenTo).not.toHaveBeenCalled();
        });
        ```
      - Add a second case asserting that the globalThis ring-buffer is NOT written when isSelfPlayer=true (sanity).
      - REMOVE (delete) the D-57b and D-58b anti-revert test cases entirely — they guard the now-absent SELF snap behavior. Per CONTEXT D-04 these are EXPLICITLY RETIRED as an acceptance criterion of this phase. Add a deleted-block tombstone comment near the top of the file: `// D-57b + D-58b anti-revert tests retired 06.7 per CONTEXT D-04 — replaced by the D-04 positive-guard above. Remote-path lerp/threshold tests RETAINED per W7 lock; updated to pass isSelfPlayer=false explicitly.`
      - **REMOTE SYMMETRY (W7 lock):** For ALL remaining lerp/threshold cases — including the below-threshold no-op case AND the above-threshold sprite-write case — update each call to pass `isSelfPlayer: false` explicitly. These tests MUST continue to pass after the signature change. If any existing remote-path test currently calls `eng.onServerSnapshot(snap)` without a flag, change to `eng.onServerSnapshot(snap, /* isSelfPlayer */ false)`. Do NOT retire the remote-path cases — they guard the remote-player rendering that the 06.7 operator UAT does NOT exercise (UAT focuses on self-player movement). Removing them risks an undetected remote-rendering regression.

      Update the mock adapter trick `apps/client/src/__test__/game-scene.test.ts:91-92` (the `ReconcileEngine: class { onServerSnapshot = vi.fn(); }` mock) only if signature change breaks the mock — the vi.fn() accepts any args, so likely no change needed. Re-run the suite to confirm.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/reconciler.test.ts</automated>
    </verify>
    <done>Reconciler self-noop is enforced + tested; D-57b/D-58b retired; D-04 positive guard passes; remote-path lerp/threshold tests preserved with explicit isSelfPlayer=false; GameScene caller updated; no Phaser-tween-fighting-predictor regression possible.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "if \(isSelfPlayer\)" apps/client/src/prediction/reconciler.ts` matches at least one line inside onServerSnapshot
      - Source assertion: `grep -nE "isSelfPlayer: boolean" apps/client/src/prediction/reconciler.ts` matches the signature
      - Source assertion: `grep -n "06.7 D-04" apps/client/src/prediction/reconciler.ts` matches the header comment
      - Source assertion (W7 lock): `grep -n "DIVERGENCE_THRESHOLD_PX" apps/client/src/prediction/reconciler.ts` matches at least one line (constant retained, NOT deprecated)
      - Source assertion (W7 lock): the remote-path sprite-write code is intact — `grep -nE "setPosition|tweenTo" apps/client/src/prediction/reconciler.ts` matches at least one line BELOW the self-guard return
      - Source assertion: D-57b and D-58b cases are GONE from the test file — `grep -cE "describe.*D-57b|describe.*D-58b|it.*D-57b|it.*D-58b" apps/client/src/__test__/reconciler.test.ts` returns 0 OR matches only the retirement tombstone comment (verify by reading the diff)
      - Source assertion: D-04 positive guard exists — `grep -n "D-04 06.7" apps/client/src/__test__/reconciler.test.ts` matches at least one line
      - Source assertion (W7 lock): at least one remote-path test uses the explicit flag — `grep -nE "isSelfPlayer.*false|/\*\s*isSelfPlayer\s*\*/\s*false" apps/client/src/__test__/reconciler.test.ts` matches at least one line
      - Source assertion: the GameScene call site passes the new flag — `grep -nE "onServerSnapshot\(\{[^}]*\},\s*(true|/\*\s*isSelfPlayer\s*\*/\s*true)" apps/client/src/scenes/GameScene.ts` matches
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/reconciler.test.ts` exits 0 with the D-04 case passing AND the preserved remote-path lerp/threshold cases passing
      - Regression assertion: `pnpm -C apps/client exec vitest run src/__test__/game-scene.test.ts` exits 0 (the mock-adapter trick still works with the new signature)
      - Tag assertion: `grep -c "impl->REQ-CLI-04" apps/client/src/prediction/reconciler.ts` ≥ 1
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: GameScene + colyseus-client wiring — instantiate dispatcher, schedule sendTick at GameScene.ts:1166-1206, handshake protocol_version=4</name>
    <read_first>
      - apps/client/src/scenes/GameScene.ts (FULL — line 79 ReconcileEngine import; line 131 reconcile field; line 709 reconcile instantiation; line 761 self-snapshot call; **line 1152 `override update(_time: number, dt: number)` — the per-frame entry**; **lines 1166-1206 `while (this.simTickAccumulator >= SIM_TICK_MS)` — the existing 30 Hz sim-tick scheduler**; **line 1168 `const localSim = this.prediction.predictTick(this.currentLayoutForSim());` — the per-tick predictTick invocation that PositionDispatcher.sendTick is wired next to**; the InputDispatcher per-tick path uses `axisX()`/`axisY()` at 1174-1175 — the dispatcher itself does not have a per-sim-tick send in this file, so PositionDispatcher.sendTick is the new 30 Hz call site)
      - apps/client/src/net/colyseus-client.ts (FULL — `bindHandlers` @ lines 237-258, the player onAdd / onChange wiring; the handshake's `joinOrCreate` call — where the `protocol_version` argument is set; current literal 3 needs to become 4)
      - apps/client/src/prediction/input-dispatcher.ts (the InputDispatcher per-tick or per-event scheduling pattern; PositionDispatcher's scheduling lives in the same scene loop)
      - apps/client/src/__test__/input-dispatcher.test.ts (test patterns for any GameScene-level dispatcher schedule, if applicable)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/net/colyseus-client.ts" §"Per-player onChange wiring"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pitfall 4" (PROTOCOL_VERSION bump deploy ritual)
      - packages/protocol/src/version.ts (Plan 01 — PROTOCOL_VERSION = 4)
    </read_first>
    <behavior>
      - GameScene instantiates a PositionDispatcher alongside InputDispatcher and stores it on the scene (private field declared alongside the existing `reconcile` field at line ~131)
      - **Scheduler hook (pre-resolved per W4):** GameScene invokes `this.positionDispatcher?.sendTick(_time)` INSIDE the existing 30 Hz `while (this.simTickAccumulator >= SIM_TICK_MS)` loop in `override update(_time, dt)` at apps/client/src/scenes/GameScene.ts:1166-1206. The call is placed IMMEDIATELY AFTER the existing `const localSim = this.prediction.predictTick(...)` invocation at line 1168 (executor reads the surrounding context lines 1166-1206 to splice the new call at the canonical spot — same loop iteration, after predictTick completes, before or after the remote-player onSimulationTickRemote loop; either ordering is acceptable since sendTick is independent of remote state). DO NOT invent a new timer. DO NOT use `performance.now()` — use the `_time` parameter already passed to `override update(_time, dt)` (it is Phaser's monotonic timestamp and is what the rest of the loop body uses for telemetry at line 1218).
      - On Colyseus reconnect, the scene calls `this.positionDispatcher?.setRoom(newRoom)` (mirroring InputDispatcher.setRoom)
      - colyseus-client.ts handshake sends `protocol_version: 4` (NOT 3) — verify literal change OR confirm the value is sourced from `PROTOCOL_VERSION` from `@rebno/protocol` and that the source value is 4 (Plan 01)
      - The reconciler self-snapshot call site in GameScene passes `isSelfPlayer: true` (Task 2 already changed this — confirm it survives the wiring edits here)
      - bindHandlers' `players.onAdd` callback still reads `player.anim_state` / `player.sprite_override` from PlayerState for remote rendering per RESEARCH §"Pitfall 8" (existing per-instance `$(player).onChange` already fires for the new fields — verify nothing in the renderer chokes on the new values)
    </behavior>
    <action>
      In `apps/client/src/scenes/GameScene.ts` — the edits are co-located in 5 regions (W6 accepted-as-is single task):

      1. **Import (line ~79 region, alongside ReconcileEngine):**
         `import { PositionDispatcher } from '../prediction/position-dispatcher.js';`

      2. **Field declaration (line ~131, alongside the existing `reconcile` field):**
         `private positionDispatcher: PositionDispatcher | undefined;`

      3. **Instantiation (line ~700-720, alongside InputDispatcher / ReconcileEngine setup):**
         `this.positionDispatcher = new PositionDispatcher(room, this.prediction, () => this.getCurrentFacing(), () => this.spriteStateMachine.isRunning());`
         — the exact callback wiring depends on the SpriteStateMachine / facing-source API in the scene; the executor reads the surrounding scene state to plug in correctly. The contract: `getFacing` returns the current Direction; `getIsRunning` returns whether the sprite is in a RUN_* pose.

      4. **Per-tick invocation — PRE-RESOLVED HOOK (W4):**
         Inside `override update(_time: number, dt: number)` at line 1152, INSIDE the existing `while (this.simTickAccumulator >= SIM_TICK_MS)` loop body (lines 1166-1206), IMMEDIATELY AFTER the existing `const localSim = this.prediction.predictTick(this.currentLayoutForSim());` call at line 1168, splice in:
         ```
         // [impl->REQ-CLI-04] [impl->REQ-CLI-08] D-05/D-06 — emit position_update at
         // the 30 Hz sim-tick boundary. Idle ticks STILL fire (D-06 steady cadence).
         this.positionDispatcher?.sendTick(_time);
         ```
         Use `_time` (Phaser's monotonic timestamp, already in scope as the first parameter of `override update`), NOT `performance.now()`. The placement is unambiguous: same iteration of the while-loop that runs predictTick, before the loop exits. Co-location with predictTick guarantees one position_update per simulation tick — including idle ticks (D-06).

      5. **Reconnect handler (search for `this.input.setRoom(newRoom)` or equivalent — likely in the room-rebind path):**
         Add `this.positionDispatcher?.setRoom(newRoom);` alongside the existing input.setRoom call.

      PRESERVE the Task 2 edit where `this.reconcile?.onServerSnapshot({ ... }, /* isSelfPlayer */ true)` is passed; confirm the call still compiles.

      In `apps/client/src/net/colyseus-client.ts`:
      - Locate the `joinOrCreate('rebno', { protocol_version: ..., session_token: ... })` call (handshake). Per RESEARCH §"Pitfall 4" the version is bumped to 4 alongside the protocol package's PROTOCOL_VERSION constant. PREFER to source the value as `protocol_version: PROTOCOL_VERSION` (imported from `@rebno/protocol`) over a hardcoded literal — this prevents future drift between the protocol package and the client. If the call currently uses a literal `3`, replace with `PROTOCOL_VERSION` and add the import. If the call already uses `PROTOCOL_VERSION` (sourced from the bumped constant), no source change is needed — the bump in Plan 01 took effect.
      - In `bindHandlers` (lines 237-258), confirm the per-player `$(player).onChange(...)` callback reads through any consumers of the new `player.anim_state` / `player.sprite_override` fields — but DO NOT add a new renderer wiring for sprite_override. Per RESEARCH §"set_sprite_override schema" the override is shipped as a STUB in 06.7 (handler + schema + state field; no consumer wiring in renderer because special poses are post-MVP). Document this with a single-line comment in bindHandlers near the player onChange callback: `// 06.7: sprite_override is stubbed — renderer consumer added when PAR-* phases ship the special-pose sprites.`
      - For `anim_state`, similarly: the existing facing-based sprite selection (06.4 D-58c precedent) already covers the 16 base poses; sending `anim_state` on the wire is forward-compat for future poses. No renderer change required in 06.7 per the PATTERNS.md §"Pitfall 8" note. Verify with a comment.

      GameScene test (`apps/client/src/__test__/game-scene.test.ts`):
      - Update the existing mock at lines ~68-92 (`reconcileInstances` + `ReconcileEngine` mock) to also include a PositionDispatcher mock if the test exercises that code path. If the existing game-scene.test.ts does not touch the dispatcher scheduling, this update is a no-op — just confirm the test still passes after the GameScene wiring edits.
      - If the test breaks because the new positionDispatcher field is undefined under the mock, add a corresponding `vi.mock('../prediction/position-dispatcher.js', ...)` block mirroring the existing ReconcileEngine mock.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/game-scene.test.ts src/__test__/colyseus-client.test.ts</automated>
    </verify>
    <done>GameScene instantiates + schedules PositionDispatcher inside the existing 30 Hz `override update` accumulator loop (lines 1166-1206), immediately after predictTick at line 1168; reconnect routes new room to the dispatcher; handshake sends protocol_version=4; bindHandlers documented for the stubbed sprite_override path; existing game-scene + colyseus-client tests still pass (with mock updates if needed).</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "PositionDispatcher" apps/client/src/scenes/GameScene.ts` matches at least 2 lines (import + instantiation)
      - Source assertion (W4 — scheduler hook placement): `grep -nE "positionDispatcher\??\.sendTick\(_time\)" apps/client/src/scenes/GameScene.ts` matches at least one line; the surrounding context (read with offset/limit) shows the call lives INSIDE the `while (this.simTickAccumulator >= SIM_TICK_MS)` loop body, after the `this.prediction.predictTick(...)` call. Specifically: `awk '/override update\(_time/,/^\s*\}$/' apps/client/src/scenes/GameScene.ts | grep -c "positionDispatcher"` ≥ 1
      - Source assertion: `grep -nE "positionDispatcher.*setRoom" apps/client/src/scenes/GameScene.ts` matches at least one line (reconnect wired)
      - Source assertion: handshake value is 4 or sourced from PROTOCOL_VERSION — either `grep -nE "protocol_version: ?4" apps/client/src/net/colyseus-client.ts` matches, OR `grep -nE "protocol_version: ?PROTOCOL_VERSION" apps/client/src/net/colyseus-client.ts` matches AND `grep -n "PROTOCOL_VERSION" packages/protocol/src/version.ts | grep -c '= 4'` ≥ 1 (Plan 01 already verified the constant)
      - Source assertion: NO literal `protocol_version: 3` remains in apps/client — `grep -rn "protocol_version: ?3" apps/client/src` returns 0 matches
      - Source assertion: 06.7 sprite_override stub comment exists — `grep -nE "06.7.*sprite_override.*stub" apps/client/src/net/colyseus-client.ts` matches at least one line
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/game-scene.test.ts src/__test__/colyseus-client.test.ts` exits 0
      - Regression assertion: `pnpm -C apps/client exec vitest run` (full client suite) exits 0
      - Build assertion: `pnpm -C apps/client exec tsc --noEmit` exits 0
      - Trace assertion: `pnpm trace:check` does not regress REQ-CLI-04 / REQ-CLI-08 stages
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → Colyseus `room.send('position_update', ...)` | Client is the source of its own position truth (per D-13 carve-out); server zod-validates the envelope only |
  | GameScene → ReconcileEngine | INTRA-process; no untrusted data. Self-path is now provably no-op; remote path preserved (W7 lock) |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-08 | Tampering (regression) | A future refactor accidentally re-enables ReconcileEngine.onServerSnapshot for self (re-introducing the diagonal-stop ~10 px drift bug from operator UAT 2026-05-16) | mitigate | The D-04 positive-guard test in `reconciler.test.ts` asserts `setPosition` + `tweenTo` are NEVER called when `isSelfPlayer=true`. The reconciler.ts file header documents the invariant. A future revert would either fail the test or require deleting the test (which is auditable). RESEARCH §"Pitfall 1". |
  | T-06.7-08b | Tampering (regression) | A future refactor accidentally collapses the remote-player branch of onServerSnapshot to a no-op, breaking remote-player rendering that the 06.7 operator UAT does not exercise | mitigate | W7 lock: remote-path lerp/threshold tests in `reconciler.test.ts` are RETAINED with explicit `isSelfPlayer=false`. Removing them would require deleting visible test cases — auditable. `DIVERGENCE_THRESHOLD_PX` constant is intentionally NOT deprecated. |
  | T-06.7-09 | Tampering (Phaser race) | An in-flight Phaser tween targeted at the local sprite races with predictor.predictTick's setPosition write, producing visual judder | mitigate / accept | Per RESEARCH §"Pitfall 6": if any GameScene code still calls `tweenTo` against the local sprite outside the now-noop reconciler, add a defensive `scene.tweens.killTweensOf(localSprite)` before the predictor write. If no such call path exists (the only previous caller was the reconciler self-path, now removed), the race is gone by construction. Task 3 documents the choice in a code comment. |
  | T-06.7-10 | DoS (client-side) | Bug in scene scheduler causes PositionDispatcher.sendTick to fire faster than 30 Hz, exceeding server rate-limit (Plan 02 token bucket = 35 tokens/sec / burst 60) | mitigate (server-side) | Server-side `rateLimitOrDrop` drops the excess; on the client, the dispatcher inherits the scene's 30 Hz scheduler — bypassing that scheduler is the only way to overshoot, and that would require a bug, not an exploit. Operator dashboard plots `rate_limit_dropped` for `msg_type='position_update'` per RESEARCH §"Pitfall 3" so a runaway client is observable. The PRE-RESOLVED hook at GameScene.ts:1166-1206 anchors the dispatcher to the SAME accumulator loop that drives predictTick — eliminating timer-drift as a misfire vector. |
  | T-06.7-11 | Tampering | A malicious client constructs a Direction value not in the FACING_OCTANT enum and passes it to `packAnimState`, producing `NaN` or `undefined` on the wire | mitigate | TypeScript types restrict the call site; zod on the server rejects out-of-enum facing. Even if a hostile fork bypasses TypeScript, the zod gate on the server (Plan 02) is the actual security boundary. Client-side packing produces a number (or `undefined | 0x08` which is `NaN`), which zod's `.int().min(0).max(255)` rejects. |

  **Doctrine cross-reference (D-13):** Plan 04 codifies the trust-model split in CLAUDE.md. This plan implements the client side of that split — self-player position lives on the client; the server is a write-through store. The D-04 positive guard in reconciler.test.ts is the regression backstop. The W7 remote-symmetry lock prevents an unrelated remote-rendering regression slipping in under the cover of the self-flip.
  </threat_model>

  <verification>
  - `pnpm -C apps/client exec vitest run` green for the full client unit suite (position-dispatcher.test.ts + reconciler.test.ts + game-scene.test.ts + colyseus-client.test.ts + others)
  - `pnpm -C apps/client exec tsc --noEmit` clean
  - `grep -rn "protocol_version: ?3" apps/client/src` returns 0 matches (no v3 literal remnant)
  - D-04 positive guard test present and passing
  - D-06 idle-cadence test present and passing
  - D-57b + D-58b retirement tombstones present in reconciler.test.ts; remote-path lerp/threshold tests retained with `isSelfPlayer=false`
  - PositionDispatcher.sendTick wired inside the 30 Hz accumulator loop at GameScene.ts:1166-1206, not on a new timer
  - `pnpm trace:check` does not regress REQ-CLI-04 / REQ-CLI-08
  </verification>

  <success_criteria>
  - Client streams c2s.position_update at the sim-tick cadence with all 9 fields correctly populated and int16-quantized, including idle frames (D-06)
  - Self-player reconciliation is provably absent — D-04 acceptance criterion satisfied
  - Remote-player reconciliation behavior is preserved — W7 lock satisfied
  - Phase 06.7-02's two-client integ (when run against this client) shows the end-to-end roundtrip with diagonal-stop drift NOT manifesting
  - Plan 04 operator UAT is unblocked (the full client+server stack runs end-to-end on staging)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-SUMMARY.md`. Capture:
  - Confirmation that the GameScene scheduler invocation reuses the existing 30 Hz boundary at lines 1166-1206 (no new timer) — record the actual post-edit line number for the `positionDispatcher?.sendTick(_time)` call.
  - Whether colyseus-client.ts now sources `protocol_version` from the constant (preferred) or a literal (acceptable but flag for cleanup).
  - Whether the Phaser tweens.killTweensOf defensive call was added or determined unnecessary (with line refs).
  - The list of D-57b/D-58b tests removed (file:line refs to the deletion) so a future archaeologist sees the deliberate retirement.
  - Confirmation that the remote-path lerp/threshold tests are retained with explicit `isSelfPlayer=false` (W7 lock).
  - Confirmation that the D-06 idle-cadence guard test passes against the implementation (the dispatcher does NOT skip idle frames).
  </output>
  </content>
  </invoke>
  ---
  #### 06.7-04-PLAN.md
  ---
  phase: 06.7
  plan: 04
  type: execute
  wave: 3
  depends_on:
    - 06.7-02
    - 06.7-03
  files_modified:
    - CLAUDE.md
    - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
    - .planning/STATE.md
    - .planning/ROADMAP.md
  autonomous: false
  requirements:
    - REQ-CLI-08
    - REQ-SRV-03
    - REQ-SRV-04
    - REQ-CLI-04
  must_haves:
    truths:
      - "CLAUDE.md Hard Rule 1 contains the D-13 narrow movement carve-out — clients are authoritative for {position, velocity, facing, anim_state}; server stays authoritative for chat origin, inventory, room transitions, persistence"
      - "Scores/combat are NOT in the Hard Rule 1 example list (REBNO has none — per D-13)"
      - "Hard Rule 1 carries the literal [doc->REQ-SRV-04] tag (W5 resolution — REQ-SRV-04 = 'Server is authoritative — clients send intent, server emits state', the doctrine anchor in traceable-reqs.toml)"
      - "06.7-HUMAN-UAT.md exists and is runnable by an operator on staging — closes diagonal-stop drift + simulated-packet-delay hitching + two-player smoke + idle-cadence 30 Hz network panel observation (W3 Test 6)"
      - "Operator UAT signed off (checkpoint:human-verify gate passes)"
      - "STATE.md reflects phase completion; ROADMAP.md Phase 06.7 entry shows plans as [x] and the phase as ready for verification"
    artifacts:
      - path: "CLAUDE.md"
        provides: "Hard Rule 1 narrow movement carve-out (D-13) tagged [doc->REQ-SRV-04]"
        contains: "CLIENT-AUTHORITATIVE"
      - path: ".planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md"
        provides: "Operator UAT script — diagonal-stop drift + dropped-packet hitching + two-player smoke + D-06 idle-cadence network-panel observation"
        contains: "Diagonal-stop"
      - path: ".planning/STATE.md"
        provides: "Phase 06.7 status update"
        contains: "06.7"
      - path: ".planning/ROADMAP.md"
        provides: "Phase 06.7 plans marked complete + UAT signed-off"
        contains: "06.7-01-PLAN.md"
    key_links:
      - from: "CLAUDE.md §Hard Rules Rule 1"
        to: "client-trust movement carve-out paragraph"
        via: "doctrine update per D-13 + literal [doc->REQ-SRV-04] tag"
        pattern: "CLIENT-AUTHORITATIVE"
      - from: "06.7-HUMAN-UAT.md"
        to: "Operator sign-off section"
        via: "explicit pass/fail per test (1-6)"
        pattern: "Sign-off"
  ---

  <objective>
  Land the doctrine change codifying the trust split (CLAUDE.md Hard Rule 1 narrow carve-out per D-13, anchored with the literal `[doc->REQ-SRV-04]` tag per W5 resolution), ship the operator UAT script that closes the two motivating bugs from operator UAT 2026-05-16 (diagonal-stop drift + dropped-packet hitching) AND adds a Test 6 idle-cadence network-panel observation (W3 — the human counterpart to the automated D-06 idle-cadence guard in Plan 03), run the UAT on staging (operator checkpoint), and update STATE/ROADMAP to reflect phase completion.

  Purpose: D-13 / D-12 — doctrine catches up to the implementation; staging-only hard-cut deployment validates the new model in place; rollback path documented (D-14 = `/gsd-undo` on phase commits).
  Output: 1 doctrine edit (with W5-pinned tag) + 1 new UAT script (with W3-added Test 6) + 1 operator checkpoint task + STATE/ROADMAP updates. The CLAUDE.md edit IS a Phase 06.7 deliverable per the planning-context "CLAUDE.md edit task IS a deliverable" instruction.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-02-PLAN.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-PLAN.md
  @CLAUDE.md
  @.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md

  <interfaces>
  <!-- Doctrine target — CLAUDE.md Hard Rules §1: -->

  Current text (locate via Grep — `grep -n "Server-authoritative" CLAUDE.md`):
  > 1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.

  Replacement per CONTEXT D-13 + PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out":
  > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent
  >    for everything EXCEPT player movement. Server emits state. Never trust client
  >    identity, chat origin, inventory, room transitions, or persistence — these
  >    are server-authoritative. **Movement (position, velocity, facing, anim_state)
  >    is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported
  >    state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger)
  >    is deferred to Phase 06.8. [doc->REQ-SRV-04]

  Notes:
  - D-13 LOCKED scope: ONE paragraph edit. No tabular restructure (deferred).
  - D-13 LOCKED scope: DROP "scores, combat" from the rule example list — REBNO has none.
  - **W5 RESOLVED:** the literal tag is `[doc->REQ-SRV-04]`. Confirmed by inspection of `traceable-reqs.toml` line 132-134: `REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"` — this is the EXACT doctrine anchor for Hard Rule 1. The executor does NOT need to grep or choose; it is pinned here.

  <!-- UAT precedent — `.planning/phases/06.1-.../06.1-HUMAN-UAT.md` structure: -->
  - ## Preconditions (staging URL, accounts, browser)
  - ## Operator Test 1, Test 2, ... (numbered steps + expected vs observed columns)
  - ## Sign-off section (operator name, date, pass/fail, notes)
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: CLAUDE.md Hard Rule 1 narrow movement carve-out (D-13) — tagged [doc->REQ-SRV-04]</name>
    <files>CLAUDE.md</files>
    <read_first>
      - CLAUDE.md (FULL — Hard Rules section, specifically Rule 1; current text is "1. **Server-authoritative.** Clients send intent..."; check the broader context to ensure no other Hard Rule depends on the current phrasing)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out (doctrine — D-13)" (exact replacement text)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-13 (LOCKED scope: paragraph edit only, no table restructure, drop scores/combat)
      - traceable-reqs.toml (verified by the planner at revision time — REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"; this IS the Hard Rule 1 doctrine anchor)
    </read_first>
    <action>
      Locate Hard Rule 1 in CLAUDE.md (line currently reading `1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.`).

      Replace the rule body with the exact text below (also reproduced in PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out"). **The literal `[doc->REQ-SRV-04]` tag is REQUIRED at the end of the paragraph (W5 resolution):**

      > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8. [doc->REQ-SRV-04]

      The tag MUST be the literal `[doc->REQ-SRV-04]`. The planner verified at revision-iteration-1 time that `traceable-reqs.toml` line 132-134 declares `REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"` with `required_stages = ["doc", "impl", "unit", "int"]` — REQ-SRV-04 is the doctrine anchor for Hard Rule 1, no further grep / choice required. Do NOT substitute `REQ-SRV-03` (which anchors "apps/server runs Node 22 + Colyseus 0.17 with one Room class") nor `REQ-CLI-04` (which anchors client-prediction discipline) — those have different titles and would muddle traceability.

      Do NOT modify any other Hard Rule. Do NOT add a table. Do NOT add Phase 06.8 details beyond the one-sentence deferral reference already in the paragraph.

      Run `pnpm trace:check` post-edit to confirm the new tag is recognised and no `undeclared_id` finding is introduced (REQ-SRV-04 is already declared in traceable-reqs.toml so the doc-stage row should now show `doc` covered).
    </action>
    <verify>
      <automated>grep -nE "CLIENT-AUTHORITATIVE" CLAUDE.md && grep -nE "\[doc->REQ-SRV-04\]" CLAUDE.md && pnpm trace:check</automated>
    </verify>
    <done>Hard Rule 1 reads with the D-13 carve-out language; the literal `[doc->REQ-SRV-04]` tag anchors the paragraph; `pnpm trace:check` passes; REQ-SRV-04 doc-stage row shows covered.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "Server-authoritative \(with narrow movement carve-out\)" CLAUDE.md` matches exactly one line
      - Source assertion: `grep -n "CLIENT-AUTHORITATIVE" CLAUDE.md` matches at least one line
      - Source assertion: `grep -n "position, velocity, facing, anim_state" CLAUDE.md` matches the carve-out scope sentence
      - Source assertion: `grep -n "scores" CLAUDE.md | grep -i "rule" -B5 | grep -c "Hard Rule 1"` returns 0 — i.e. the word "scores" no longer appears in Hard Rule 1 (per D-13 drop). (Scores may appear elsewhere in the file; the assertion scopes to Hard Rule 1 context. If grep glue is fragile, the simpler check: read Hard Rule 1 directly and confirm "scores" is absent.)
      - Source assertion (W5 — pinned tag): `grep -nE "\[doc->REQ-SRV-04\]" CLAUDE.md` matches at least one line in the Hard Rule 1 paragraph
      - Trace assertion: `pnpm trace:check` exits 0 with no `undeclared_id` or `parse_error` findings for the new tag; REQ-SRV-04 doc-stage row is covered by the new CLAUDE.md tag
    </acceptance_criteria>
  </task>

  <task type="auto">
    <name>Task 2: Create 06.7-HUMAN-UAT.md operator script (with W3 Test 6 — idle-cadence network-panel observation)</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <read_first>
      - .planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md (FULL — structure precedent: preconditions, numbered operator tests, sign-off section)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §"Motivating bugs" (D-02 — diagonal-stop ~10 px shift; dropped-packet hitching) + §D-06 (steady 30 Hz cadence; idle frames still emit)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"06.7-HUMAN-UAT.md"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Validation Architecture" row "REQ-CLI-08 manual-only"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-VALIDATION.md (the new row: "D-06 idle cadence guard — PositionDispatcher.sendTick emits valid payload when getLocalState returns {vx:0, vy:0}" — the automated half lives in Plan 03 Task 1; this human-UAT Test 6 is the manual half)
    </read_first>
    <action>
      Create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`. Tag the file `[doc->REQ-CLI-08]` near the top. Structure mirrors `.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md`:

      ### Preconditions
      - Staging deployed with phase 06.7 commits (server + client)
      - PROTOCOL_VERSION = 4 confirmed live
      - Two browser sessions (Chrome) with two distinct operator accounts authed
      - Chrome DevTools Network conditions panel accessible (for packet-delay simulation AND for Test 6 frame-count observation)
      - Operator IP allowlisted on staging (per memory note 2026-05-08 — re-verify session IP)

      ### Test 1: Diagonal-stop drift bug closed (D-02a)
      Reproduction steps from the original 2026-05-16 UAT:
      - Step 1: log in, navigate to BNCentral spawn
      - Step 2: press W+D (UR diagonal) for 2 seconds
      - Step 3: release BOTH keys simultaneously
      - Expected: sprite stops at its current pixel position with NO horizontal shift (no ~10 px snap right or left)
      - Observed: ___ (operator fills in)
      - Pass/Fail: ___

      ### Test 2: Dropped-packet hitching closed (D-02b)
      - Step 1: open Chrome DevTools → Network → Throttling → "Slow 3G" (or custom: 5% packet loss / 150ms RTT)
      - Step 2: hold W (run north) for 5 seconds
      - Expected: continuous smooth movement (each position_update is self-describing per the 06.7 model; single dropped packet invisible at 30 Hz)
      - Observed: ___
      - Pass/Fail: ___
      - Note: if hitching appears, capture HAR + screenshot; this is the regression-on-this-bug signal.

      ### Test 3: Two-player smoke (REQ-CLI-08 hard milestone — regression confirm)
      - Step 1: Operator A logs in on Browser 1; Operator B logs in on Browser 2; both join BNCentral
      - Step 2: A and B see each other's sprites
      - Step 3: A walks in a square (W, D, S, A for 1 sec each); B observes A moving smoothly (no rubber-band, no diagonal-stop drift visible on remote view)
      - Step 4: B walks in a square; A observes same smoothness
      - Step 5: Both send chat messages; chat appears with correct origin attribution
      - Expected: all four observation checks pass
      - Observed: ___
      - Pass/Fail: ___

      ### Test 4: PROTOCOL_VERSION 3 client rejected (deploy-ritual verification — D-12)
      - Step 1: Open the staging URL with `?protocol_version_override=3` (or however the dev-tooling allows forcing a downgraded handshake — confirm the override mechanism with the codebase before authoring; if no override exists, skip this test and note that automated integ in Plan 02 covers it)
      - Step 2: Attempt to join
      - Expected: connection rejected with PROTOCOL_VERSION_MISMATCH (close 4400)
      - Observed: ___
      - Pass/Fail: ___

      ### Test 5: Rollback drill (D-14 — `/gsd-undo` works)
      - Step 1: Capture current staging commit SHA
      - Step 2: Do NOT actually run /gsd-undo (this is a paper drill); verify the runbook references the commit SHAs of plans 01-04 as the rollback target
      - Expected: documented rollback target is the 4 phase commits
      - Observed: ___
      - Pass/Fail: ___

      ### Test 6: Idle-cadence steady 30 Hz network confirm (D-06 — REQ-CLI-04 — W3 addition)
      Closes the manual half of the D-06 validation row from 06.7-VALIDATION.md. The automated half is the unit test in Plan 03 Task 1 (`apps/client/src/__test__/position-dispatcher.test.ts` — "D-06 06.7 — sendTick emits a valid payload when player is idle").
      - Step 1: log in to BNCentral; do NOT press any movement keys
      - Step 2: open Chrome DevTools → Network panel → filter for WS frames on the Colyseus connection (the websocket carrying `position_update`)
      - Step 3: leave the player idle for 5 seconds, watching the websocket frames panel
      - Expected: approximately 150 `position_update` frames over the 5-second window (30 Hz × 5 s = 150 frames; acceptable range ~120–180 to allow for browser scheduler jitter). Each frame carries the same x/y and vx=0/vy=0. The cadence is STEADY — no gaps longer than ~100 ms.
      - Observed (rough frame count over 5 s): ___
      - Observed (any gaps > 100 ms?): ___
      - Pass/Fail: ___
      - Note: if frame count is dramatically lower (e.g. < 30 over 5 s), the dispatcher is gating on movement — a D-06 violation; capture the websocket trace + the dispatcher source diff and route to gap-closure.

      ### Sign-off
      - Operator: ___
      - Date: ___
      - Overall pass/fail: ___
      - Phase 06.7 ready for `/gsd-verify-work`: yes / no
      - Open issues (if any): ___

      Tag the file body with at least one `[doc->REQ-CLI-08]` AND one `[doc->REQ-CLI-04]` (the D-06 idle-cadence row in 06.7-VALIDATION.md maps to REQ-CLI-04; Test 6 closes the manual half of that row). The file is the manual-only complement to the automated CLI-08 integ from Plan 02 + the D-06 idle-cadence unit from Plan 03 Task 1 — together they close REQ-CLI-08 and the D-06 row of REQ-CLI-04 for this phase.
    </action>
    <verify>
      <automated>test -f .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -cE "^### Test [1-6]:" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -n "doc->REQ-CLI-08" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -n "doc->REQ-CLI-04" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
    </verify>
    <done>Operator UAT script exists with 6 tests (1-5 original + 6 = W3 idle-cadence network-panel observation) + sign-off section; both REQ-CLI-08 and REQ-CLI-04 doc tags present; structure mirrors the 06.1 precedent.</done>
    <acceptance_criteria>
      - File exists: `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`
      - Source assertion: `grep -cE "^### Test [1-6]:" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` returns ≥ 6 (Test 1..Test 6 sections present — W3 Test 6 included)
      - Source assertion: `grep -n "Diagonal-stop" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion: `grep -n "Dropped-packet\|packet loss\|packet delay" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion: `grep -n "Two-player\|two-player" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion (W3 — Test 6 idle-cadence observation): `grep -nE "Idle-cadence|idle.*5 sec|30 position_update frames|150 .*frames" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line in Test 6
      - Source assertion: `grep -n "Sign-off" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Tag assertion: `grep -c "doc->REQ-CLI-08" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` ≥ 1
      - Tag assertion (W3 — REQ-CLI-04 D-06 row anchored): `grep -c "doc->REQ-CLI-04" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` ≥ 1
      - Trace assertion: `pnpm trace:check` exits 0
    </acceptance_criteria>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <name>Task 3: Operator UAT checkpoint — run 06.7-HUMAN-UAT.md on staging and sign off</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <read_first>
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md (just authored in Task 2)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-12 (hard cut on staging) §D-14 (rollback path)
    </read_first>
    <action>
      Pause for operator. The operator opens 06.7-HUMAN-UAT.md, executes the SIX tests on staging in sequence (Tests 1-5 + the W3 Test 6 idle-cadence observation), records Observed + Pass/Fail per test, fills in the Sign-off section, and commits the updated UAT file. Claude does NOT automate this task — the entire point is operator-feel verification of the two motivating bugs (D-02a diagonal-stop drift + D-02b dropped-packet hitching) that no automated test captures, plus the D-06 idle-cadence behavior observed via DevTools (Test 6) which the unit test in Plan 03 cannot observe on a real browser. See <what-built> and <how-to-verify> below for the operator-facing instructions.
    </action>
    <what-built>
      Phase 06.7 ships the client-trust movement model end-to-end:
      - Plan 01: protocol surface (cPositionUpdateSchema, cSetSpriteOverrideSchema, packAnimState/unpackAnimState, PlayerState fields, PROTOCOL_VERSION=4)
      - Plan 02: server handlers + tickLoop platforms-only + protocol v4 handshake test
      - Plan 03: PositionDispatcher (with D-06 idle-cadence guard test) + reconciler self-noop (D-04) + protocol_version=4 handshake + remote-symmetry preserved (W7 lock)
      - Plan 04 Task 1: CLAUDE.md Hard Rule 1 narrow carve-out (D-13) — tagged `[doc->REQ-SRV-04]` (W5)
      - Plan 04 Task 2: Operator UAT script — 6 tests including Test 6 (D-06 idle-cadence — W3)

      Pre-checkpoint sanity: ensure the latest staging deploy includes plans 01+02+03 commits BEFORE running this checkpoint. If staging is behind, kick off a deploy first.
    </what-built>
    <how-to-verify>
      1. Open `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`.
      2. Execute Tests 1-6 in sequence on staging.
         - For each test, record the Observed and Pass/Fail values.
         - Test 6 specifically asks for a websocket frame count over 5 idle seconds — leave the player idle and watch the DevTools Network → WS panel. Expected ~150 frames (30 Hz × 5 s); acceptable 120–180.
         - If ANY test fails: do NOT sign off; capture artifacts (HAR, screenshot, console log); document in the UAT file's "Open issues" section; bring the failure back to the planner via gap-closure flow.
         - If all tests pass: fill in the Sign-off section (operator name, date, "Pass") and commit the updated UAT file.
      3. After sign-off, confirm:
         - `pnpm trace:check` passes (and REQ-SRV-04 doc row now shows the CLAUDE.md tag covering it)
         - `pnpm -r test` (full workspace test) is green — including the new D-06 idle-cadence unit
         - `pnpm -r typecheck` is clean
         - The four phase commits are pushed to staging branch
      4. Type "approved" (or describe failures + recommended next step).
    </how-to-verify>
    <verify>
      <automated>grep -nE "Overall pass/fail: (Pass|pass|PASS)" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -nE "Operator: \S" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
    </verify>
    <done>Operator has filled in the Sign-off section of 06.7-HUMAN-UAT.md with their name + date + an overall Pass; all six tests recorded as Pass or with documented open issues that are non-blocking for this phase; commit landed.</done>
    <resume-signal>Type "approved" to mark UAT passed and advance to Task 4 (STATE/ROADMAP wrap), or paste failure details + recommended next step (gap-closure plan / rollback / other).</resume-signal>
    <acceptance_criteria>
      - Source assertion: `grep -nE "Overall pass/fail: (Pass|pass|PASS)" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches the post-sign-off line
      - Source assertion: every `### Test N:` block (N=1..6) has a filled-in `Observed:` value (not just the `___` placeholder) — visual scan of the UAT file
      - Behavior assertion: operator typed "approved" in the chat or paste-back, confirming gate passed
      - Regression assertion: `pnpm -r test && pnpm -r typecheck && pnpm trace:check` all exit 0 at sign-off time
    </acceptance_criteria>
  </task>

  <task type="auto">
    <name>Task 4: Update STATE.md + ROADMAP.md to reflect Phase 06.7 completion</name>
    <files>.planning/STATE.md, .planning/ROADMAP.md</files>
    <read_first>
      - .planning/STATE.md (current phase status section)
      - .planning/ROADMAP.md (Phase 06.7 entry @ lines 200-214; the existing plans list is "TBD")
      - The completed 06.7-01/02/03/04 SUMMARY files (produced by each plan's <output> stanza)
    </read_first>
    <action>
      Update `.planning/STATE.md`:
      - Move Phase 06.7 to a "completed" or "verification-pending" status as appropriate to the project's STATE convention.
      - Record the four phase commit SHAs (one per plan) with one-line summaries.
      - Note the operator UAT pass-date from Task 3.
      - Add the deferred-to-06.8 items: server-side legality check, speed-cap, walkable-grid sanity, fall reset (per CONTEXT D-11 + Deferred Ideas).
      - Add the deferred-to-06.9-cleanup items: c2s.input + heldInputs removal (per RESEARCH §Open Question 3).

      Update `.planning/ROADMAP.md`:
      - In the Phase 06.7 section (around line 200-214), replace the "TBD" plan list with the four phase plans, all checked off:
        ```
        Plans:
        - [x] 06.7-01-PLAN.md — Wave 1 — Protocol surface: cPositionUpdateSchema + cSetSpriteOverrideSchema + packAnimState + PlayerState fields + PROTOCOL_VERSION 3→4 (REQ-SRV-03, REQ-CLI-04, REQ-CLI-08)
        - [x] 06.7-02-PLAN.md — Wave 2 — Server: position_update + set_sprite_override handlers + tickLoop platforms-only + protocol-v4 handshake integ (REQ-SRV-03, REQ-SRV-14, REQ-CLI-08)
        - [x] 06.7-03-PLAN.md — Wave 2 — Client: PositionDispatcher (+D-06 idle-cadence guard) + reconciler self-noop (D-04) + remote symmetry preserved (W7) + protocol_version=4 handshake (REQ-CLI-04, REQ-CLI-08)
        - [x] 06.7-04-PLAN.md — Wave 3 — CLAUDE.md Hard Rule 1 carve-out (D-13, [doc->REQ-SRV-04]) + operator HUMAN-UAT (6 tests incl. D-06 idle-cadence) + STATE/ROADMAP wrap (REQ-CLI-08, REQ-SRV-03, REQ-SRV-04, REQ-CLI-04)
        ```
      - Update **Plans:** TBD line to `**Plans:** 4 plans (all complete, operator UAT signed off YYYY-MM-DD)`.
      - DO NOT mark the phase as `[x]` at the roadmap top-level until `/gsd-verify-work 06.7` confirms — the wrap-up task stops at "complete-pending-verify".

      Add a brief line near the bottom of the ROADMAP Phase 06.7 entry noting the future 06.8 dependency: "Anti-cheat (illegal-position fall trigger + speed-cap + walkable-grid sanity) deferred to candidate Phase 06.8 per CONTEXT D-11."
    </action>
    <verify>
      <automated>grep -cE "^\- \[x\] 06\.7-(01|02|03|04)-PLAN\.md" .planning/ROADMAP.md && grep -n "06.7" .planning/STATE.md | head -5</automated>
    </verify>
    <done>STATE + ROADMAP reflect Phase 06.7 complete-pending-verify; the four plans are checked off in ROADMAP; deferred 06.8 items are documented.</done>
    <acceptance_criteria>
      - Source assertion: `grep -cE "^\- \[x\] 06\.7-(01|02|03|04)-PLAN\.md" .planning/ROADMAP.md` returns exactly 4
      - Source assertion: `grep -n "operator UAT signed off" .planning/ROADMAP.md` matches a line in the Phase 06.7 section
      - Source assertion: `grep -n "06.7" .planning/STATE.md` matches at least one line (phase reflected in current state)
      - Source assertion: `grep -n "candidate Phase 06.8" .planning/ROADMAP.md` matches the deferred-item annotation
      - Trace assertion: `pnpm trace:check` exits 0 (no doc-stage regression)
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Doctrine (CLAUDE.md) → Future planners / agents | Hard Rule 1 IS the trust-boundary specification; doctrine drift is the long-term Tampering risk |
  | Operator UAT script → Operator | Manual-only path; operator's judgment closes the feel-based regressions automated tests can't |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-12 | Tampering (doctrine drift) | A future planner accidentally re-asserts "Never trust client positions" in a new doctrine paragraph, contradicting Hard Rule 1's carve-out | mitigate | Hard Rule 1 paragraph explicitly states "CLIENT-AUTHORITATIVE as of Phase 06.7" with a phase reference; `pnpm trace:check` enforces the doc-stage tag exists; the literal `[doc->REQ-SRV-04]` tag anchors the paragraph to the traceability table. Future doctrine edits should NOT re-tag REQ-SRV-04 from a contradictory paragraph; if they do, a reviewer notices the duplication and reconciles. |
  | T-06.7-13 | Repudiation (operator UAT) | Operator signs off without actually running the tests | accept | Solo-developer workflow; the operator is the user. Trust model degrades at the doctrine layer if the operator self-deceives. Mitigation is structural (numbered tests 1-6 with explicit pass/fail per item, including a frame-count observation in Test 6 that is hard to fake without artifacts); no further enforcement. |
  | T-06.7-14 | Information Disclosure (rollback drill) | The phase commit SHAs documented in STATE.md leak in a way that helps attackers — unlikely but possible if the repo accidentally goes public | accept | Per CLAUDE.md Hard Rule 8, the repo stays private through Phase 7. Phase commit SHAs are public-safe (no secrets in commit content); the rollback drill is purely procedural. |

  **Doctrine continuity (D-13 cross-check):** The CLAUDE.md edit in Task 1 closes the doctrine loop opened by Plans 01-03 (which implemented the trust split but did not codify it). Without this edit, a future planner reading only CLAUDE.md would mis-architect (e.g. add server-side position derivation that double-writes player.x). Task 1 is therefore as load-bearing as the implementation plans. **W5 pin:** the tag is `[doc->REQ-SRV-04]`, no executor ambiguity.

  **Operator UAT-driven safety:** Three of the motivating outcomes (D-02a diagonal-stop drift, D-02b dropped-packet hitching, D-06 idle-cadence steady 30 Hz) are operator-feel or browser-observable regressions that no headless automated test captures cleanly. Test 1 + Test 2 + Test 6 close these explicitly; without the UAT, "the bug is closed" / "the cadence holds in a real browser" is unverifiable.
  </threat_model>

  <verification>
  - CLAUDE.md Hard Rule 1 reads with D-13 language and the literal `[doc->REQ-SRV-04]` tag (W5)
  - `pnpm trace:check` passes (no undeclared_id from the new doc tag; REQ-SRV-04 doc-stage row now covered)
  - 06.7-HUMAN-UAT.md present and complete — 6 tests including Test 6 (D-06 idle-cadence, W3)
  - Operator checkpoint (Task 3) returns "approved" — or returns failure detail that gets escalated to gap-closure
  - STATE.md / ROADMAP.md updated post-sign-off
  - `pnpm -r test` + `pnpm -r typecheck` green across the workspace (regression confirmation against the combined Plan 01+02+03 deltas — including the new D-06 idle-cadence unit)
  </verification>

  <success_criteria>
  - Doctrine, implementation, and observability are aligned: CLAUDE.md describes what the code does (with the W5-pinned [doc->REQ-SRV-04] tag); the code does what the operator UAT demands (including the W3 Test 6 idle-cadence observation); the operator UAT confirms the two motivating bugs from operator UAT 2026-05-16 are closed AND that D-06 holds in a real browser
  - /gsd-verify-work 06.7 has everything it needs (4 plans complete, operator UAT signed off, traceable-reqs clean, full test suite green)
  - The deferred-to-06.8 items are documented so a future planner has a starting point
  - The deferred-to-06.9 cleanup items (heldInputs vestigial, cInputSchema vestigial) are also documented
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-04-SUMMARY.md`. Capture:
  - Final CLAUDE.md Hard Rule 1 text (exact post-edit form, including the `[doc->REQ-SRV-04]` tag) for archival
  - Operator UAT outcome (date, pass/fail per test 1-6, any captured artifacts including the Test 6 frame-count value)
  - The four phase commit SHAs for the rollback target
  - The deferred items list for 06.8 (anti-cheat) and 06.9-cleanup (heldInputs)
  - A one-line recommendation: ready for /gsd-verify-work 06.7
  </output>
  </content>
  </invoke>
  ## Review Instructions

  Analyze the plans and provide:
  1. **Summary** — one-paragraph assessment
  2. **Strengths** — bullet points
  3. **Concerns** — bullets with severity (HIGH/MEDIUM/LOW)
  4. **Suggestions** — specific improvements
  5. **Risk Assessment** — overall LOW/MEDIUM/HIGH with justification

  Focus on:
  - Missing edge cases or error handling
  - Dependency ordering issues
  - Scope creep / over-engineering
  - Security (the D-11 acceptance of zero server-side anti-cheat is explicit — flag only if you see a NEW security gap unrelated to D-11)
  - Performance (50 CCU, 30 Hz cadence, Colyseus TCP/WS, single Fly.io machine)
  - Whether the plans actually achieve the phase goal: flip self-player movement to client-trust + reconciler no-op for self, fix diagonal-stop drift + dropped-packet hitching, keep remote symmetry

  Output review in markdown.

preview:
  # Cross-AI Plan Review Request

  You are reviewing implementation plans for a software project phase.
  Provide structured feedback on plan quality, completeness, and risks.

  ## Project Context
  # REBNO — Rebuild BN Online

  ## What This Is

  REBNO is a ground-up modernization of *BN Online*, a late-1990s/early-2000s multiplayer game shipped as a GameMaker 5.3a `.gmd` project. The work extracts the original client and server source from `BN Online Client 5-8.gmd` and `BN Online Master 5-4.gmd`, documents engine + server features, then rebuilds both halves on a modern web stack (TypeScript everywhere, Node server on Fly.io, Phaser 3 *or* PixiJS client running in Chrome) at full feature parity with a modernized UX.

  ## Core Value

  **Original BN Online players can open Chrome, log in with their migrated account, walk around, and chat with each other in real time — bug-free.** Everything else (graphics polish, full feature parity, scaling) flows from that working multiplayer baseline.

  ## Requirements

  ### Validated

  <!-- Brownfield: existing artifacts in repo. Code/wiki present, but nothing new shipped yet. -->

  - ✓ GameMaker 5.3a reverse-engineering wiki (17 docs in `decomp/wiki/`) — existing
  - ✓ Legacy archive of all `.gmd` revisions, server source, Unity remake attempt, Maya assets, audio — existing
  - ✓ Codebase map (`.planning/codebase/`, 7 docs) — existing
  - ✓ Stage 1 (Extraction) complete — Validated in Phase 1: extraction (2026-05-03)
    - `tools/extract-gmd/` TS Node CLI ports `LateralGM GmFileReader` semantics; 157/157 tests
    - `extracted/client-5-8/` (8307 files) + `extracted/server-5-4/` (1144 files) committed; reproducibility byte-identical
    - 13 wire-format bugs uncovered + documented in `decomp/wiki/03-gmd-format.md` errata
    - Sprite/background image data carried as opaque `imageBytes` (Option A pivot — BMP→PNG decode deferred to Phase 6/7 asset-pipeline; EXT-04 acceptance relaxed accordingly)

  ### Active

  #### Stage 1 — Extraction (DONE — Phase 1, 2026-05-03)

  #### Stage 2 — Client analysis (DONE — Phase 2, 2026-05-04)

  #### Stage 3 — Server analysis (DONE — Phase 3, 2026-05-05; status: human_needed — Linux CI verify-phase-3 deferred per HUMAN-UAT.md)

  #### Stage 4 — Server rebuild (NEXT)
  - [ ] Reimplement server in Node + TypeScript with binary WebSocket transport
  - [ ] Refactor as code is ported: identify and eliminate bugs from the original
  - [ ] Migrate original user accounts to a modern hash (bcrypt/argon2) — preserve usernames, force password reset on first login OR transparent re-hash on first successful auth
  - [ ] Authoritative server model — clients never trust each other for game state
  - [ ] Room/level layouts stored server-side and transmitted to client over the wire on room entry, integrity-verified; client bundle ships no static layout data (SRV-13)
  - [ ] Moving-platform live positions deterministic via `packages/game-logic` `step()` and broadcast authoritatively to all clients in the same state-diff envelope as player positions (SRV-14)

  #### Stage 5 — Deploy
  - [ ] Deploy new server to Fly.io with persistent volume + Litestream backup (or Postgres)
  - [ ] CI/CD pipeline: push to main → deploy
  - [ ] Health checks, structured logging, basic ops dashboard

  #### Stage 6 — Client rebuild (MVP)
  - [ ] Web client (Phaser 3 or PixiJS, TypeScript, Vite) running in Chrome
  - [ ] Modernized UX (HiDPI, responsive, keyboard + gamepad input)
  - [ ] **MVP gate: two players join the same room, see each other move, exchange chat messages — multiplayer over the new server**

  #### Stage 7 — Full parity
  - [ ] All client features from Stage 2 documentation reimplemented
  - [ ] All server features from Stage 3 documentation reimplemented
  - [ ] Original-game test cases pass against new client + server

  ### Out of Scope

  - **Mobile / native ports** — Chrome-first; no iOS, Android, Steam binaries in v1
  - **Engine-level emulation of GameMaker 5.3a** — we reimplement game *logic*, not the GM5 runtime
  - **Re-running the original Windows binary** — preservation belongs in `legacy/`, not the rebuild
  - **Server-side anti-cheat beyond authoritative model** — no behavioral detection in v1
  - **Modding API / user-generated content** — out of scope until parity ships
  - **3D / VR / new gameplay features** — faithful rebuild first, then iterate
  - **Public marketing / asset rights clearance** — license + distribution decision deferred until rebuild is functional (see Key Decisions)

  ## Context

  - **Repo layout** (see `.planning/codebase/STRUCTURE.md`): `decomp/wiki/` holds RE knowledge, `legacy/` holds all original artifacts (audio, maya-project, open-source-release, servers, source-archive, unity-project). Two prior remake attempts exist (`legacy/unity-project`, `legacy/open-source-release`); both can inform the new rebuild but are not the authoritative source — the `.gmd` files are.
  - **Source-of-truth files**: `legacy/open-source-release/BN Online Client 5-8.gmd` (client) and `legacy/open-source-release/BN Online Master 5-4.gmd` (server). Older revisions in `legacy/source-archive/` and `legacy/servers/` are reference-only.
  - **Engine constraints to overcome** (from wiki): GM5.3a is Delphi 5 / Win32, modern decompilers don't read it, network code is 39dll-only (TCP, no native sockets), assets are ZLIB-compressed inside a monolithic XOR-obfuscated payload appended to the runner stub. Extraction pipeline is documented in `decomp/wiki/15-extraction-pipeline.md`.
  - **Proprietary formats**: `.bno` / `.bnb` save data is bespoke — only readable by walking the extracted GML's `file_bin_*` call order, not via external tools.
  - **Bit-rot / IP risks** flagged in `.planning/codebase/CONCERNS.md` (229 lines) — read before committing scope.

  ## Constraints

  - **Tech stack — server**: Node + TypeScript. Mature ecosystem, best WS tooling (`ws`, `uWebSockets.js`), strong type story for shared client/server packet definitions.
  - **Tech stack — client**: TypeScript + Phaser 3 (preferred) or PixiJS (fallback). Decision deferred to end of Stage 2 once feature surface is known. Must run in Google Chrome, no plugins.
  - **Tech stack — transport**: WebSocket binary frames. Authoritative server, clients send intent, server emits state.
  - **Tech stack — persistence**: TBD in Stage 3 (likely SQLite + Litestream for <50 player target; Postgres if data model demands relational queries).

  ## Phase 06.7: Network model — client-trust + server illegal-position fall trigger
  ### Roadmap Section
  ### Phase 06.7: Network model shift to client-trust + server illegal-position fall trigger (INSERTED)\r\n\r\n**Goal**: Flip the player-movement trust model so client owns its position and server stores client-reported position as authoritative state for movement only. Reconciler becomes a no-op for the self-player (still applied to remote players where server state IS authoritative). Server retains authority over chat origin, inventory, scores, combat math, room transitions. As an anti-cheat backstop, server runs tick-by-tick `isPositionLegal(player.x, player.y, room.walkable_grid)`; if illegal for > N ticks (~250-500 ms grace), server triggers a fall event = forced animation + position reset to last-known-legal or designated respawn. The fall mechanism is the second deliverable (Phase B in the seed) and is what makes client-trust safe at the 50 CCU target. Update CLAUDE.md Hard Rule 1 to clarify movement-specific trust split.\r\n\r\n**Motivation**: Operator UAT 2026-05-16 surfaced choppy movement on staging — root cause is reconciler snap-back from client-prediction-vs-server-truth drift (e.g. diagonal stop produces ~10 px horizontal shift). Core REBNO value depends on predictable movement; original BNO was peer-to-peer 39dll where clients trusted each other's positions, so the design space precedent matches.\r\n\r\n**Depends on:** Phase 06.6\r\n**Requirements**: REQ-SRV-03, REQ-SRV-14, REQ-CLI-04, REQ-CLI-08\r\n**Plans:** 4 plans\n\nPlans:\n- [ ] 06.7-01-PLAN.md — Wave 1 — Protocol surface: cPositionUpdateSchema + cSetSpriteOverrideSchema + packAnimState + PlayerState fields + PROTOCOL_VERSION 3→4 (REQ-SRV-03, REQ-CLI-04, REQ-CLI-08)\n- [ ] 06.7-02-PLAN.md — Wave 2 — Server: position_update + set_sprite_override handlers + tickLoop platforms-only + protocol-v4 handshake integ (REQ-SRV-03, REQ-SRV-14, REQ-CLI-08)\n- [ ] 06.7-03-PLAN.md — Wave 2 — Client: PositionDispatcher + reconciler self-noop (D-04) + protocol_version=4 handshake (REQ-CLI-04, REQ-CLI-08)\n- [ ] 06.7-04-PLAN.md — Wave 3 — CLAUDE.md Hard Rule 1 carve-out (D-13) + operator HUMAN-UAT + STATE/ROADMAP wrap (REQ-CLI-08, REQ-SRV-03)\r\n\r\n**Risks to weigh during discuss/plan:**\r\n- Cheaters can teleport visually (mitigated by fall-reset, but visible to other players briefly until grace expires).\r\n- Two players claiming same position → server arbitration needed for inter-player collisions.\r\n- Position-update spam → rate-limit at game tick rate (30 Hz per Extracted Constants).\r\n- Reconciler refactor + tests need a feature-flag transition path; do NOT bundle this with any in-flight phase.

  ### Requirements Addressed
  REQ-SRV-03, REQ-SRV-14, REQ-CLI-04, REQ-CLI-08 (+ doctrine anchor REQ-SRV-04)

  ### User Decisions (CONTEXT.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger - Context

  **Gathered:** 2026-05-17
  **Status:** Ready for planning

  <domain>
  ## Phase Boundary

  Flip the **self-player movement trust model** from hybrid client-prediction + server-authoritative-reconciler to **client-authoritative position**. Client sends absolute `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}` at 30 Hz; server stores the client-reported state as authoritative and broadcasts to remote players. Reconciler becomes a **no-op for the self-player** (the remote-player path is unchanged — server-state IS authoritative for those).

  Server retains full authority over **non-movement state**: chat origin, inventory, room transitions, persistence. (REBNO has no scores or combat in scope.)

  Motivating bugs (operator UAT 2026-05-16, staging):
  - ~10 px horizontal shift on diagonal stop → root cause is reconciler snap from client-vs-server position drift in `apps/client/src/prediction/reconciler.ts`.
  - Occasional movement hitching that appears packet-loss correlated → current sync-strict reconciler assumes a perfect network; absolute-position wire + client-trust naturally absorbs single-packet drops.

  **Anti-cheat (Phase B in the seed) is OUT OF SCOPE.** Server-side legality check + fall reset is deferred to backlog (candidate Phase 06.8) — to be triggered only when cheating becomes observable. Pre-launch staging is a trusted operator pool at 50 CCU target, so the abuse window is acceptable.

  </domain>

  <decisions>
  ## Implementation Decisions

  ### Scope

  - **D-01:** Phase A only — flip self-player to client-trust + reconciler no-op. Phase B (fall reset + legality check) → backlog (candidate Phase 06.8).
  - **D-02:** Motivating bugs explicitly in scope: (a) ~10 px diagonal-stop drift on staging; (b) dropped-packet hitching tolerance. Current model assumes perfect network — fix that.
  - **D-03:** Inter-player collision arbitration is **out of scope** (not currently observed; theoretical risk only).
  - **D-04:** D-57b and D-58b anti-revert regression tests are **explicitly retired** as an acceptance criterion of this phase. They guard reconciler behavior that is being removed for the self-player. They are replaced by a regression test asserting **the self-player sprite position is never written by `ReconcileEngine.onServerSnapshot`** (positive guard against future revert).

  ### Wire shape + cadence

  - **D-05:** Position-update wire shape (client → server, every tick): `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}`. **Absolute position** (not deltas) — single dropped packet is recovered cleanly by the next.
  - **D-06:** Cadence = **30 Hz steady**, regardless of motion. Idle player sends `{x, y, vx=0, vy=0, facing, anim_state=STAND_*, ...}` 30 times/sec. Matches Minecraft's `PlayerPosition` cadence model and keeps the server's authoritative store always-fresh for remote-player broadcasts.
  - **D-07:** **Int16 quantization** on `x, y` — client snap-rounds to integer pixels before sending. Matches the BNO snap-round model already in `packages/game-logic/src/step.ts:117-122`. Removes float-comparison footguns. Halves packet size vs float.
  - **D-08:** `anim_state` is a 1-byte enum carried in **every position_update** (NOT derivable from kinematics — moving platforms, conveyor platforms, and hexport all produce velocity-without-running states; future spectator/watching pose too). Enum covers 8 directions × {stand, run} + reserved slots for special poses. The existing `SpriteStateMachine` in `packages/game-logic/src/sprite-state-machine.ts` continues to drive client-side animation selection; its current output is what gets shipped.
  - **D-09:** Special non-derivable sprite states (HexportIn / HexportOut / TeleIn / TeleOut / ncol overlays / jokershell / watching) ship via a separate **`set_sprite_override`** event-only intent. Rare event triggers — no place in the 30 Hz hot path. Mirrors the `set_facing` precedent shipped in 06.4 round-3 (D-58c).
  - **D-10:** `facing` field stays in position_update (NOT folded into `anim_state`). Facing = "where the player is pointing" intent (D-58c set_facing semantics); `anim_state` = "what pose to render right now". Different semantics, both needed.

  ### Anti-cheat

  - **D-11:** **Zero server-side anti-cheat in 06.7.** Server trusts the client-reported position fully. Rationale: 50 CCU target, pre-launch private staging, trusted operator pool. Speed-cap + walkable-grid sanity + fall reset → all deferred to future 06.8 (or whenever cheating becomes observable on real-user traffic).

  ### Migration + doctrine

  - **D-12:** **Hard cut on staging.** No feature flag. Staging is the only deployment target pre-launch; operator UAT validates the new model in place. If staging regresses, rollback = `/gsd-undo` on phase commits.
  - **D-13:** **CLAUDE.md Hard Rule 1 — narrow movement carve-out.** Add one paragraph: "Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE — server stores client-reported state. Non-movement state (chat origin, inventory, room transitions, persistence) remains server-authoritative." Drop scores/combat from the example list — REBNO has no plans for those. Restructuring Rule 1 into a tabular trust matrix is **out of scope** (broader rewrite for a future phase if doctrine grows more complex).
  - **D-14:** **Rollback strategy = `/gsd-undo` phase commits.** No dead-code branches kept around.

  ### Research directive (for /gsd-plan-phase 06.7)

  - **D-15:** **Minecraft Java Edition is the canonical reference model.** Researcher (gsd-phase-researcher) must produce a comparison table in RESEARCH.md scoring at minimum:
    - **Minecraft Java Edition** (`PlayerPosition` packet, server-side `Player#move` validation, force-teleport correction)
    - **Quake / Tribes lineage** (client-prediction + server-reconcile — what REBNO is moving AWAY from)
    - **Current REBNO model** (hybrid prediction + 22 px reconciler snap/lerp)
    
    ...against the constraints: 50 CCU target, predictable-movement (core REBNO value), dropped-packet tolerance, single Fly.io machine, Colyseus 0.17 transport.

  - **D-16:** Dropped-packet tolerance is a first-class research dimension. Researcher should investigate: how often is a missed `position_update` survivable for the remote-player path? Do we need server-side extrapolation between packets, or does 30 Hz + absolute-position + Colyseus transport reliability make it irrelevant?

  ### Claude's Discretion

  - Exact `anim_state` enum members + numeric byte values — pick during planning based on existing `SpriteStateMachine` states. Add a binary-format note to `packages/protocol` matching int16 quantization.
  - Whether to keep the `seq` echo / `last_input_seq` round-trip on the position_update path or drop it (current `cInputSchema` uses seq for ordering — likely keep for monotonic ordering + idempotent retries).
  - Server-side `step()` retention strategy — server's authoritative step still drives platforms + future Phase B fall checks. Whether to keep it running on a separate tick for self-player platform-effects (conveyor application) vs. treating client's reported velocity as the truth.
  - Whether to send remote-player broadcast diffs at full 30 Hz or downsampled (server-side fan-out optimization — researcher input).

  ### Folded Todos
  None — the 2026-05-16 split-playwright-smoke todo (score 0.9) keyword-matched on "staging/uat/operator" but is a CI workflow refactor, not a network-model concern. Reviewed-only; see Deferred Ideas.

  </decisions>

  <canonical_refs>
  ## Canonical References

  **Downstream agents MUST read these before planning or implementing.**

  ### Network-model precedent (external — researcher to fetch via WebSearch / WebFetch)
  - **Minecraft Java Edition `PlayerPosition` / `PlayerPositionAndLook` packet semantics** — official protocol docs (wiki.vg or equivalent). The canonical implementation pattern of client-trust + server force-correct. Researcher to produce a comparison table per D-15.
  - **Minecraft `Player#move` server-side validation** — `maxMoveDistanceSquared`, terrain check, force-teleport response. Reference even though REBNO Phase B is deferred — informs the future 06.8 plan.

  ### Current REBNO model (read these to understand what is being changed)
  - `apps/client/src/prediction/reconciler.ts` — `ReconcileEngine.onServerSnapshot` (the 22 px threshold + lerp/snap logic that becomes a no-op for self-player). 123 LOC.
  - `apps/client/src/prediction/predictor.ts` — `PredictionEngine` (self-player local prediction + ack/replay; need to determine whether this stays for input-driven local responsiveness even with client-trust).
  - `apps/client/src/prediction/input-dispatcher.ts` — current `c2s.input` axes wire path (D-09 keydown/keyup events + D-58c `set_facing` precedent for client-authoritative state).
  - `apps/server/src/RebnoRoom.ts` §621-622 — server-side spawn position assignment + tick loop (1389 LOC; pay attention to player.x / player.y writes — those become assignments from the position_update message, not from `step()` output).
  - `apps/server/src/onMessageHandlers.ts` — zod-validated wire intent handlers. New `position_update` + `set_sprite_override` validators belong here.
  - `packages/game-logic/src/step.ts` — pure deterministic step. Becomes server-side platform-effects-only for self-player; remote-player broadcast pass-through. Animation derivation **NOT possible** from x/y/vx/vy per D-08.
  - `packages/game-logic/src/sprite-state-machine.ts` — SpriteStateMachine that produces `anim_state`; ship its current output verbatim on the wire.
  - `packages/game-logic/src/collision.ts` — collision primitives (still needed for client-side prediction-of-self for input responsiveness; server walkable-grid check is deferred to 06.8).
  - `packages/protocol/src/intents.ts` — `cInputSchema` (the precedent shape for the new `position_update` schema).

  ### Project doctrine to update
  - `CLAUDE.md` §"Hard Rules" Rule 1 — narrow movement carve-out per D-13. **This update is part of the 06.7 deliverable**, not a side effect.
  - `.planning/REQUIREMENTS.md` — REQ-SRV-03 / REQ-SRV-14 / REQ-CLI-04 / REQ-CLI-08 references (no requirement change needed — the trust-model swap is a refactor of how those reqs are satisfied, not a redefinition).

  ### Prior-phase context to carry forward
  - `.planning/phases/06.4-cycle-5-gap-closure-d-51b-d-55b-d-55c-d-57b-d-58b-d-60-d-61-/` — D-58c set_facing precedent (client-authoritative wire intent for a state field previously derived server-side). The new `position_update` follows the same playbook.
  - `.planning/phases/06.6-uat-accounts-integer-viewport-scaling-chat-region-clamp-righ/06.6-CONTEXT.md` — prior phase context style + decision-numbering convention.

  ### Phase seed
  - `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-SEED.md` — promoted from the 2026-05-16 todo; full problem statement and Phase A / Phase B framing.

  </canonical_refs>

  <code_context>
  ## Existing Code Insights

  ### Reusable Assets
  - **`SpriteStateMachine` (`packages/game-logic/src/sprite-state-machine.ts`)** — already produces the animation pose enum client-side. Wire its output as `anim_state` on every position_update; no new state machine needed.
  - **`set_facing` intent (06.4 D-58c)** — exact precedent for client-authoritative state field shipped on a wire intent. Copy the validator + handler + broadcast pattern.
  - **`cInputSchema.strict()` zod validator (`packages/protocol/src/intents.ts`)** — the security pattern: zod validates everything on the wire before the server consumes it. Apply identically to `position_update` (sanity-validates int16 range, enum membership, monotonic seq) even though no anti-cheat envelope ships in 06.7.
  - **`HEARTBEAT_INTERVAL_MS = 15_000` in `input-dispatcher.ts`** — heartbeat-keepalive pattern. Position_update at 30 Hz IS the heartbeat — no separate keepalive needed.

  ### Established Patterns
  - **Deterministic `step()` in `packages/game-logic`** — pure function, no I/O, runs identically in browser and Node. Stays useful for client-side input-responsiveness prediction (self-player) and for platform/conveyor effect application. Remote-player position is no longer derived from `step()` — it's stored from the wire.
  - **Threat-model invariant T-06.1.02-01 (step.ts:151-158 comment)** — server never trusts client-fabricated walkable_grid. Compatible with 06.7: server still doesn't consume client grid; it just stores client-reported position. Document the new trust line clearly in CLAUDE.md per D-13.
  - **REQ-SRV-14 deterministic broadcast envelope** — the existing state-diff envelope from Phase 4 stays. `position_update` from a client mutates the authoritative `PlayerState`; broadcast envelope unchanged.
  - **`globalThis.__rebno.lastReconcile*` diagnostic ring buffer** (`reconciler.ts:73-92`) — operator-debugging surface. Remove cleanly during 06.7; if a self-reconciler regression slips in, this is the canary that resurfaces.

  ### Integration Points
  - **`onMessageHandlers.ts`** — add `position_update` handler (validate via zod, write into PlayerState, broadcast in next tick) + `set_sprite_override` handler.
  - **`RebnoRoom.ts` tick loop** — server `step()` no longer computes self-player position from axes; instead it consumes the most-recent client-reported `{x, y, vx, vy, facing, anim_state}` and broadcasts. Platform/conveyor application TBD per Claude's Discretion above.
  - **`apps/client/src/net/colyseus-client.ts`** — replace the axes-only dispatcher path with the position_update dispatcher. Existing `input-dispatcher.ts` keydown/keyup events still drive **local** SpriteStateMachine; the dispatcher swap is "what we send", not "how we read input".
  - **`apps/client/src/prediction/reconciler.ts`** — self-player branch becomes no-op (return early when `snap.account_id === local.account_id`). Remote-player branch unchanged. Document the split clearly in the file header.
  - **`apps/client/src/prediction/predictor.ts`** — likely stays for client-side local input-to-position prediction (instant input responsiveness without round-trip). Researcher to confirm during planning.

  </code_context>

  <specifics>
  ## Specific Ideas

  - **Minecraft Java Edition is the explicit precedent.** User asked specifically how Minecraft implements its semi-server-authoritative movement and confirmed it as a research reference. The pattern (client-trust + server validation as separate concerns) is the structural blueprint for 06.7 (Phase A) + 06.8 (future Phase B).
  - **Absolute position on every packet (not deltas).** User reasoned about packet-loss tolerance from first principles: the current model "effectively assumes a perfect network connection", which it shouldn't. Mirroring Minecraft's absolute-position wire makes single-packet drops invisible.
  - **Animation cannot be derived from kinematics.** User caught and corrected the initial idea of server-deriving `anim_state` from `(x, y, vx, vy)`. Counter-examples: moving platforms (velocity-without-running), conveyor platforms (velocity-without-running), hexport (motion-with-special-pose). Ship `anim_state` on the wire explicitly.
  - **No scores or combat in REBNO scope.** User clarified during Hard Rule 1 discussion. Rule 1 carve-out language must reflect the actual REBNO domain (chat, inventory, room transitions, persistence), not generic MMO doctrine.
  - **Trust-fully on staging.** User explicitly rejected speed-cap-as-cheap-first-line and logging-only options for 06.7. Pre-launch + 50 CCU + private = no anti-cheat envelope justifies the implementation cost yet.

  </specifics>

  <deferred>
  ## Deferred Ideas

  - **Phase B — server illegal-position detection + fall reset.** Tick-by-tick `isPositionLegal(x, y, walkable_grid)` check; if illegal > N ticks (~250-500 ms grace), trigger fall event = forced animation + position reset to last-known-legal-position or designated respawn. Anti-cheat backstop. **Candidate Phase 06.8** — trigger when cheating becomes observable on real-user traffic OR before public launch, whichever comes first. Needs BNO legacy fall-spec extraction (drift falls, void falls, respawn target) as research input.
  - **Inter-player collision arbitration.** Two clients claiming overlapping positions — server picks one canonical (latest-update-wins / lower-account-id / etc.) OR client-side visual nudge offset. Defer until observed in operator UAT.
  - **Speed-cap server-side validation** (`max_delta_per_tick = RUN_SPEED * 1.5`). Cheap first-line cheat detection. Backlog for 06.8 alongside fall reset.
  - **Walkable-grid sanity check server-side.** Server verifies client position is on a walkable tile. Requires server to compute walkable_grid (currently client-only per T-06.1.02-01). Backlog for 06.8.
  - **CLAUDE.md trust-doctrine table restructure.** Replace Rule 1 prose with a per-domain authority matrix. Defer until doctrine grows complex enough to justify the rewrite — Rule 1 narrow carve-out (D-13) is the right move now.
  - **Server-side `step()` future role.** Whether server keeps running `step()` for platform/conveyor effect application (server applies platform vx/vy to player position) vs. trusting the client's reported position to already include platform-carry. Planner + researcher input needed.

  ### Reviewed Todos (not folded)
  - `2026-05-16-split-playwright-smoke-into-3rd-workflow-job.md` (area: ci, score 0.9) — Operator-suggested 06.5 follow-up to split `deploy-staging.yml` `full-path` into separate deploy + smoke jobs so `gh run watch` can distinguish "deploy succeeded but smoke failed" from "deploy failed". **Out of scope for 06.7** — different domain (CI workflow refactor, not network model). Keyword-match was on "staging/uat/operator", not on substance. Stays in `.planning/todos/pending/` for a future CI-polish phase.

  </deferred>

  ---

  *Phase: 06.7-network-model-client-trust-fall-trigger*
  *Context gathered: 2026-05-17*

  ### Research Findings (RESEARCH.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger — Research

  **Researched:** 2026-05-17
  **Domain:** Real-time multiplayer netcode trust model (client-authoritative position with absolute-position state replication)
  **Confidence:** HIGH for stack/architecture; MEDIUM for downsampling and broadcast-cadence recommendations (validated against Minecraft analog, but specific Colyseus 0.17 fan-out telemetry will only come from staging UAT)

  <user_constraints>
  ## User Constraints (from CONTEXT.md)

  ### Locked Decisions

  **Scope**
  - **D-01:** Phase A only — flip self-player to client-trust + reconciler no-op. Phase B (fall reset + legality check) → backlog (candidate Phase 06.8).
  - **D-02:** Motivating bugs explicitly in scope: (a) ~10 px diagonal-stop drift on staging; (b) dropped-packet hitching tolerance. Current model assumes perfect network — fix that.
  - **D-03:** Inter-player collision arbitration is **out of scope** (not currently observed; theoretical risk only).
  - **D-04:** D-57b and D-58b anti-revert regression tests are **explicitly retired** as an acceptance criterion of this phase. They guard reconciler behavior that is being removed for the self-player. Replaced by a regression test asserting **the self-player sprite position is never written by `ReconcileEngine.onServerSnapshot`** (positive guard against future revert).

  **Wire shape + cadence**
  - **D-05:** Position-update wire shape (client → server, every tick): `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}`. **Absolute position** (not deltas) — single dropped packet recovered cleanly by the next.
  - **D-06:** Cadence = **30 Hz steady**, regardless of motion. Idle player sends `{x, y, vx=0, vy=0, facing, anim_state=STAND_*, ...}` 30 times/sec. Matches Minecraft's `PlayerPosition` cadence model and keeps the server's authoritative store always-fresh for remote-player broadcasts.
  - **D-07:** **Int16 quantization** on `x, y` — client snap-rounds to integer pixels before sending. Matches the BNO snap-round model already in `packages/game-logic/src/step.ts:117-122`. Removes float-comparison footguns. Halves packet size vs float.
  - **D-08:** `anim_state` is a 1-byte enum carried in **every position_update** (NOT derivable from kinematics — moving platforms, conveyor platforms, hexport all produce velocity-without-running states). Enum covers 8 directions × {stand, run} + reserved slots. The existing `SpriteStateMachine` in `packages/game-logic/src/sprite-state-machine.ts` continues to drive client-side animation selection; its current output is what gets shipped.
  - **D-09:** Special non-derivable sprite states (HexportIn/Out, TeleIn/Out, ncol overlays, jokershell, watching) ship via a separate **`set_sprite_override`** event-only intent. Mirrors `set_facing` precedent shipped in 06.4 round-3 (D-58c).
  - **D-10:** `facing` stays in position_update (NOT folded into `anim_state`). Facing = "where the player is pointing"; `anim_state` = "what pose to render right now". Different semantics, both needed.

  **Anti-cheat**
  - **D-11:** **Zero server-side anti-cheat in 06.7.** Server trusts client-reported position fully. 50 CCU, pre-launch private staging, trusted operators — speed-cap, walkable-grid sanity, and fall reset all deferred to 06.8.

  **Migration + doctrine**
  - **D-12:** **Hard cut on staging.** No feature flag. Rollback = `/gsd-undo` on phase commits.
  - **D-13:** **CLAUDE.md Hard Rule 1 — narrow movement carve-out.** One paragraph: "Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE — server stores client-reported state. Non-movement state (chat origin, inventory, room transitions, persistence) remains server-authoritative." Drop scores/combat from the example list — REBNO has none.
  - **D-14:** **Rollback strategy = `/gsd-undo` phase commits.** No dead-code branches.

  **Research directive**
  - **D-15:** Researcher must produce a comparison table scoring Minecraft Java Edition vs. Quake/Tribes lineage vs. current REBNO model against constraints: 50 CCU, predictable-movement, dropped-packet tolerance, single Fly.io machine, Colyseus 0.17.
  - **D-16:** Dropped-packet tolerance is a first-class research dimension.

  ### Claude's Discretion
  - Exact `anim_state` enum members + numeric byte values — pick during planning based on existing `SpriteStateMachine` states. Add a binary-format note to `packages/protocol`.
  - Whether to keep `seq`/`last_input_seq` echo on the position_update path or drop it.
  - Server-side `step()` retention — whether to keep it running on a separate tick for self-player platform-effects (conveyor application) vs. trusting client's reported velocity.
  - Whether to send remote-player broadcast diffs at full 30 Hz or downsampled (server fan-out optimization).

  ### Deferred Ideas (OUT OF SCOPE for 06.7)
  - **Phase B — server illegal-position detection + fall reset** (→ candidate 06.8).
  - **Inter-player collision arbitration** (defer until observed).
  - **Speed-cap server-side validation** (→ 06.8).
  - **Walkable-grid sanity check server-side** (→ 06.8).
  - **CLAUDE.md trust-doctrine table restructure** (Rule 1 narrow carve-out only for now).
  - **Server-side `step()` future role** (planner + researcher input — addressed below).
  </user_constraints>

  <phase_requirements>
  ## Phase Requirements

  | ID | Title (from `traceable-reqs.toml`) | Research Support |
  |----|-----------------------------------|------------------|
  | REQ-SRV-03 | `apps/server` runs Node 22 + Colyseus 0.17 with one Room class for the MVP slice | Server-side: new `position_update` + `set_sprite_override` handlers added to `onMessageHandlers.ts`; tick-loop in `RebnoRoom.ts` modified to skip `step()` for self-player movement (covered in "Architecture Patterns" below) |
  | REQ-SRV-14 | Deterministic moving-platform sync via game-logic step; clients interpolate, never extrapolate | Platforms remain server-authoritative — they are world state, NOT player movement. The carve-out is narrow: only player x/y/vx/vy/facing/anim_state flip to client-trust. Platforms stay derived from `step()` (covered in "Pitfalls" + "Architecture Patterns") |
  | REQ-CLI-04 | Client-side prediction + entity interpolation + server reconciliation | Reconciler becomes no-op for self-player; entity interpolation/extrapolation for REMOTE players UNCHANGED (server state IS authoritative for those). The req remains satisfied — its implementation morphs (covered in "Don't Hand-Roll" + "Architecture Patterns") |
  | REQ-CLI-08 | MVP GATE: two players join, move, and chat over deployed server | Two-player movement smoothness regression — the existing 06.4 e2e integration tests against staging confirm REQ-CLI-08 still holds post-flip. Specifically: `apps/client/src/__test__/colyseus-client.test.ts` + server `event-driven-input.integ.test.ts` (covered in "Validation Architecture") |

  **Required stages** for each: `doc` + `impl` mandatory for all; `unit` for REQ-SRV-03/SRV-14/CLI-04; `int` for REQ-SRV-03/SRV-14/CLI-08.
  </phase_requirements>

  ## Summary

  The phase flips player-movement trust from hybrid client-prediction + server-authoritative-reconciler to **client-authoritative absolute-position streaming**, matching Minecraft Java Edition's `Set Player Position` model. Client streams `{x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}` at 30 Hz; server stores the most-recent payload and broadcasts to remote players. The reconciler becomes a no-op for the self-player; the remote-player extrapolation path is preserved because the server-stored value IS authoritative for those peers.

  Three findings drive the recommendation:

  1. **Minecraft's model is the industry-standard precedent.** Minecraft sends absolute-position `Set Player Position` packets at 20 Hz, server stores them as authoritative state, and uses force-teleport `Synchronize Player Position` only as a correction backstop (the latter being REBNO's deferred 06.8). The "trusted store + corrective override" pattern is exactly Phase A + Phase B.
  2. **30 Hz absolute position over TCP/Colyseus is robust to single-packet loss by construction.** Each packet is fully self-describing; missing tick N's update just means tick N+1's update lands one frame later. No state divergence is possible because there's no delta to lose context for. Server-side extrapolation between packets is unnecessary at 30 Hz with Colyseus's TCP ordering guarantee.
  3. **Custom server-side broadcast fan-out optimization (downsampling) is premature.** At 50 CCU × 30 Hz × ~20 B payload, broadcast bandwidth is ~37 kbps down per client — well within Fly.io shared-cpu-1x. Ship at full 30 Hz; revisit if profiling shows fan-out cost.

  **Primary recommendation:** Implement the wire shape exactly as D-05 specifies, ship at 30 Hz steady, keep `seq` for ordering/idempotent-retries (existing `cInputSchema` precedent), drop the server-side `step()` call for self-player position entirely (it stops being authoritative for x/y), retain `step()` for platforms (REQ-SRV-14 unchanged), broadcast at full 30 Hz patch rate, use a 1-byte packed `anim_state` enum with bits 0-2 = octant + bit 3 = running + bits 4-7 reserved.

  ## Architectural Responsibility Map

  | Capability | Primary Tier | Secondary Tier | Rationale |
  |------------|-------------|----------------|-----------|
  | Self-player position (x, y) | Browser/Client | API/Backend (storage only) | D-05 — client owns its position; server is a write-through store |
  | Self-player velocity (vx, vy) | Browser/Client | API/Backend (storage only) | Derived from intent on the client; server stores for broadcast only |
  | Self-player anim_state | Browser/Client | API/Backend (storage only) | D-08 — non-derivable from kinematics; client SpriteStateMachine output is canonical |
  | Self-player facing | Browser/Client | API/Backend (storage only) | D-10 — D-58c set_facing precedent extended to every-tick streaming |
  | Remote-player rendering | Browser/Client | — | Client consumes server-broadcast state and runs existing extrapolation cap (250 ms per ADR 0007) |
  | Remote-player state-of-record | API/Backend | — | Server is the canonical store of every player's last-reported {x, y, vx, vy, facing, anim_state}; clients receive via Colyseus state-diff |
  | Platform position | API/Backend | — | REQ-SRV-14 unchanged: deterministic, derived from `step()` on the server, NOT carved out |
  | Chat origin / sender identity | API/Backend | — | Hard Rule 1 unchanged — server-tagged from `auth.account_id` per `onMessageHandlers.ts:182-188` |
  | Inventory / persistence | API/Backend | Database/Storage | Hard Rule 1 unchanged |
  | Room transitions | API/Backend | — | Hard Rule 1 unchanged — `room_join` handler is the authority |
  | Spawn position | API/Backend | — | `RebnoRoom.findHomePortal()` assigns; client adopts on join. Only the FIRST authoritative position; after spawn, client owns it |
  | Anti-cheat enforcement | (none in 06.7) | — | Deferred to 06.8 |

  ## Standard Stack

  ### Core (already pinned — no changes)
  | Library | Version | Purpose | Why Standard |
  |---------|---------|---------|--------------|
  | `colyseus` | 0.17.10 | Authoritative-server framework, room/state-diff/WS transport | `[VERIFIED: apps/server/package.json:32]` — already in use; no version change |
  | `@colyseus/sdk` | 0.17.42 | Client-side Colyseus | `[VERIFIED: apps/client/package.json:21]` — already in use |
  | `@colyseus/schema` | 4.0.23 | Schema-delta encoder for the `PlayerState`/`RoomState` MapSchemas | `[VERIFIED: apps/server/package.json:21]` — already in use |
  | `phaser` | 3.90.0 | Client-side renderer | `[VERIFIED: apps/client/package.json:25]` — already in use |
  | `zod` | 3.23.x | Wire validator at the onMessage boundary | `[VERIFIED: apps/server/package.json:45]` — already in use; new `cPositionUpdateSchema` adds here |
  | `msgpackr` | 1.11.10 | S2C event codec | `[VERIFIED: packages/protocol/src/events.ts:7]` — already in use; no new S2C event needed |

  ### Supporting — no new libraries

  The phase introduces **zero new dependencies**. Everything is a refactor of how the existing libraries are wired.

  **Version verification:** Pins above are the **current** versions in `package.json` and were verified against the files in this session. No dependency upgrades are part of the phase.

  ### Alternatives Considered (and rejected per CONTEXT)
  | Instead of | Could Use | Why Rejected |
  |------------|-----------|--------------|
  | Colyseus state-diff (PlayerState mutation) for broadcast | A new msgpackr s2c `position_broadcast` event | The Colyseus state-diff path already broadcasts `PlayerState` mutations at `setPatchRate(TICK_MS)`. Re-using it costs zero new wire shapes. Adding a parallel msgpackr broadcast would create two sources of truth and split the test surface. |
  | Float64 x/y on the wire | Float32 or fixed-point Q16.16 | D-07 locks int16. Float64 is 4× larger than int16; pixel-quantized positions don't need sub-integer precision because step.ts already snap-rounds before write. |
  | Binary packed position_update | msgpackr-encoded object | The existing `cInputSchema` lives as a JSON-shaped object (Colyseus message channel does its own packing). Binary packing would gain ~10-20 B per frame at the cost of a parallel codec test surface. Defer unless 50 CCU CPU profile shows the JSON path as a hot spot. |

  ## Architecture Patterns

  ### System Architecture Diagram

  ```
  Self-player flow (NEW in 06.7):
    Keyboard input  →  InputDispatcher (client)
                         ↓
                       PredictionEngine.predictTick(layout)   [LOCAL FEEL — kept]
                         ↓ (writes localState.x/y/vx/vy)
                       Phaser sprite render               [INSTANT RESPONSE]
                         ↓
                       PositionDispatcher.send(position_update)  [NEW: every 30 Hz tick]
                         │
                         │     wire: {x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms}
                         │     binary: int16+int16+int16+int16+u8+u8+u32+u32 = 18 B payload
                         ↓
                [ Colyseus WS / TCP — reliable, ordered ]
                         ↓
                       RebnoRoom.onMessage('position_update')
                         ↓
                       cPositionUpdateSchema.safeParse()  [zod strict — D-04]
                         ↓ (pass) write into PlayerState.{x, y, vx, vy, facing, anim_state}
                         │
                         │
                Tick loop:
                RebnoRoom.setSimulationInterval(TICK_MS=50)
                         ↓
                       step() runs for PLATFORMS ONLY (REQ-SRV-14)
                         │   no longer reads/writes self-player position
                         ↓
                       Colyseus setPatchRate(TICK_MS=50)
                         ↓
                [ broadcast PlayerState diffs to all clients ]
                         ↓
                Remote-player flow (UNCHANGED):
                       PlayerState.onChange (per-field)
                         ↓
                       PlayerRenderer.setRemotePosition (extrapolation cap 250 ms)
                         ↓
                       deriveFrameWithAuthoritativeFacing (06.4 D-58c — kept)
                         ↓
                       remote sprite render

    Self-reconciler path (REMOVED):
                       ReconcileEngine.onServerSnapshot
                         ↓ for self-player: NO-OP (early return when snap.account_id === local.account_id)
                         ↓ for remote-player: NOT INVOKED (remote path uses PlayerRenderer directly, NOT ReconcileEngine)
                         │   → Effectively dead code for self; can be removed entirely once 06.8 confirms no future need
  ```

  Key data-flow change vs. the current model:
  - **Before:** keypress → c2s.input (axes) → server.step() → state diff → reconciler (self & remote)
  - **After:** keypress → c2s.position_update (absolute x/y/vx/vy/facing/anim_state from client predictor) → state diff (passthrough) → renderer (remote only)

  ### Recommended Project Structure

  No structural changes. All file paths exist; this is a refactor:

  ```
  packages/protocol/src/
  ├── intents.ts              # ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema, AnimState enum
  ├── events.ts               # unchanged (force_reset event reserved for 06.8 fall trigger)
  └── version.ts              # BUMP PROTOCOL_VERSION 3 → 4 (wire shape change)

  apps/server/src/
  ├── onMessageHandlers.ts    # ADD: position_update handler + set_sprite_override handler
  └── RebnoRoom.ts            # MODIFY: tick loop — step() no longer writes self-player x/y/vx/vy/facing

  apps/client/src/
  ├── prediction/
  │   ├── predictor.ts        # KEEP — local-feel prediction stays for input responsiveness
  │   ├── reconciler.ts       # MODIFY: no-op for self-player; remote unchanged. Remove globalThis.__rebno.lastReconcile* writes for self
  │   └── input-dispatcher.ts # ADD: per-tick PositionDispatcher branch alongside the keydown/keyup InputDispatcher
  └── net/
      └── colyseus-client.ts  # MODIFY: bindHandlers wires position_update dispatch on local player tick

  CLAUDE.md                   # MODIFY: Hard Rule 1 narrow carve-out per D-13
  ```

  ### Pattern 1: Absolute-position client-trust streaming (Minecraft-canonical)

  **What:** Client computes its position locally and ships absolute coordinates to the server every tick. Server stores the latest payload as authoritative state and broadcasts to remote peers. Server does not compute, validate, or correct positions.

  **When to use:** Pre-launch private games where the trust boundary can include the player; games where predictable feel matters more than anti-cheat; small player counts.

  **Example (server side):**
  ```typescript
  // Source: pattern derived from packages/protocol/src/intents.ts:45-59 + apps/server/src/onMessageHandlers.ts:104-136
  // [CITED: Minecraft "Set Player Position" packet — minecraft.wiki/w/Java_Edition_protocol/Packets]
  export const cPositionUpdateSchema = z.object({
    type: z.literal('position_update'),
    x: z.number().int().min(-32768).max(32767),
    y: z.number().int().min(-32768).max(32767),
    vx: z.number().int().min(-32768).max(32767),
    vy: z.number().int().min(-32768).max(32767),
    facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    anim_state: z.number().int().min(0).max(255),  // packed enum (see below)
    seq: z.number().int().min(0),
    monotonic_at_ms: z.number().nonnegative(),
  }).strict();

  ctx.room.onMessage('position_update', (client, raw) => {
    const auth = ctx.getAuth(client.sessionId);
    if (!auth) return;
    if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;
    const parsed = cPositionUpdateSchema.safeParse(raw);
    if (!parsed.success) return;
    const player = ctx.room.state.players.get(client.sessionId);
    if (!player) return;
    player.x = parsed.data.x;
    player.y = parsed.data.y;
    player.vx = parsed.data.vx;
    player.vy = parsed.data.vy;
    player.facing = parsed.data.facing;
    player.anim_state = parsed.data.anim_state;
    player.last_input_seq = parsed.data.seq;  // keep — see "Pattern 4"
  });
  ```

  ### Pattern 2: Server-side step() retains platform authority (REQ-SRV-14 preserved)

  **What:** The server still runs `step()` every tick for platforms (and any future world state). Players are skipped entirely in the player loop within `step()`.

  **When to use:** Whenever the carve-out is partial — only one entity type (player position) flips to client-trust while everything else stays server-authoritative.

  **Example:**
  ```typescript
  // Source: derived from packages/game-logic/src/step.ts:65-235
  // Modification: skip the player-input-application block; players carry the values written
  // by the position_update handler. Platforms still advance per p.x += p.vx * dt_ms.

  export function step(state, inputs, dt_ms) {
    // 1. Advance platforms (UNCHANGED — REQ-SRV-14)
    const newPlatforms = new Map();
    for (const [id, p] of state.platforms) {
      newPlatforms.set(id, { x: p.x + p.vx * dt_ms, y: p.y + p.vy * dt_ms, vx: p.vx, vy: p.vy, cycle_phase: p.cycle_phase + dt_ms });
    }
    // 2. Players: pass-through. The position_update handler already wrote authoritative values.
    //    REMOVED: input-axis decoding, diagonal normalization, per-axis sub-pixel collision.
    return { rev: state.rev + 1, rng_state: state.rng_state, players: state.players, platforms: newPlatforms, room_layout: state.room_layout };
  }
  ```

  **Important nuance:** the existing `step()` signature is preserved; client-side prediction in `predictor.ts` still calls `step()` with the client's local walkable_grid, so client-side wall-slide collision is preserved. The change is purely server-side: the server no longer derives player movement from input axes — but `step()` still exists and still gets called for platforms.

  ### Pattern 3: Self-reconciler no-op (positive guard)

  **What:** When a server snapshot arrives for the local player, the reconciler returns early. The renderer keeps using the predictor's local state, never written by the snapshot path.

  **When to use:** When the server is no longer the authoritative source for self-player position (the client IS).

  **Example:**
  ```typescript
  // Source: derived from apps/client/src/prediction/reconciler.ts:52
  // Replace onServerSnapshot's self-player branch with an early return:

  onServerSnapshot(snap: ServerSnapshot, isSelfPlayer: boolean): void {
    if (isSelfPlayer) {
      // [impl->REQ-CLI-04] D-04 acceptance criterion: self-player sprite is
      // NEVER written by the snapshot path. Self-player position comes from
      // PredictionEngine.predictTick + InputDispatcher only.
      return;
    }
    // Remote-player path unchanged — but note that remote rendering does NOT
    // currently go through ReconcileEngine at all (it goes through
    // PlayerRenderer.setRemotePosition). So this branch is effectively dead
    // code today; we retain the signature for symmetry and the (unused) call
    // site, but the entire ReconcileEngine class is a candidate for removal
    // after staging UAT confirms no remote-player consumer.
  }
  ```

  ### Pattern 4: Keep `seq` echo on position_update (recommended)

  **What:** Client increments a per-message sequence number on every position_update; server echoes the last-received seq in `PlayerState.last_input_seq`.

  **When to use:** Whenever you want ordering/staleness detection and idempotent retries on reconnect.

  **Why keep it:** Three concrete benefits, all cheap:
  1. **Staleness detection:** Server can ignore an out-of-order position_update (`parsed.data.seq <= player.last_input_seq`). Defends against the rare TCP reorder-after-reconnect case.
  2. **Reconnect coherency:** After SRV-06's 10 s grace reconnect, the client may have local seq=N; sending one position_update advances the server's last_input_seq cleanly.
  3. **Existing infrastructure reuse:** `PlayerState.last_input_seq` is already a `@colyseus/schema` field; no protocol change to keep it.

  **Cost:** 4 bytes per frame (u32). Trivial.

  ### Anti-Patterns to Avoid

  - **Don't recompute position server-side after the position_update arrives.** The whole point of the flip is that the client owns it. Running `step()` then overwriting with the client value would just be wasted CPU AND introduce a race window where the rendered/broadcast value flickers between the two.
  - **Don't extrapolate remote players on the server.** Colyseus state-diff fans out per `setPatchRate`; the existing client-side extrapolator (apps/client extrapolation cap 250 ms per ADR 0007) already handles brief gaps. Server extrapolation duplicates this logic on the wrong side of the boundary.
  - **Don't drop the heartbeat semantics.** The existing 15 s `HEARTBEAT_INTERVAL_MS` in `input-dispatcher.ts` survives reconnect-grace. With position_update at 30 Hz, the heartbeat is implicit (any frame proves liveness) — but keep the explicit `c2s.heartbeat` handler for the case where the player is in a menu / chat-input-focused state and is NOT streaming position_updates (movement is paused per `pauseMovement()`).
  - **Don't ship `anim_state` as a string.** D-08 calls for 1-byte enum; sending `"NaviRunDR_003"` per frame at 30 Hz is 14+ bytes of bandwidth waste vs. 1 byte for the pose enum + cycle phase derived client-side from velocity.

  ## Don't Hand-Roll

  | Problem | Don't Build | Use Instead | Why |
  |---------|-------------|-------------|-----|
  | Position broadcasting | A new msgpackr `s2c.position_broadcast` event | The existing Colyseus `PlayerState` MapSchema delta channel | Already pinned at `setPatchRate(TICK_MS)`; already test-covered; adding a parallel msgpackr broadcast doubles the test surface and creates two sources of truth |
  | Position ordering | A custom sequence-window state machine | Existing `last_input_seq` field on `PlayerState` + the seq from D-05 | The seq field exists, is wire-tested, and the comparator is one line: `if (seq <= player.last_input_seq) return;` |
  | Self-player visual smoothing | A new "client-side smoothing buffer" between predict and render | Existing `PredictionEngine.predictTick` + Phaser sprite.setPosition each tick | Local feel is already instant; the smoothing buffer of yesteryear is an artifact of round-trip latency, which the client-trust model eliminates |
  | Server-side anti-cheat envelope | A speed-cap, walkable-grid, illegal-position machine | NOTHING in 06.7 | D-11 LOCKS this. Backlog for 06.8 |
  | Remote-player extrapolation | A new extrapolation algorithm | Existing `PlayerRenderer.setRemotePosition` + extrapolation cap per ADR 0007 + `axis_x_held`/`axis_y_held` on PlayerState | Already shipped; the new `vx`/`vy` fields from position_update populate the same `PlayerState.vx`/`vy` the renderer already consumes |
  | Packet-loss handling | A custom retransmit / sequence-gap detector | Nothing — Colyseus runs over TCP/WS which is reliable + ordered | TCP guarantees delivery and order. The only "loss" possible is the connection drop, which is already handled by SRV-06's 10 s grace |

  **Key insight:** Every component the phase needs already exists; the work is rewiring trust direction, not building new mechanisms.

  ## Comparison Table (D-15)

  Scored against constraints: **50 CCU**, **predictable-movement** (core REBNO value), **dropped-packet tolerance**, **single Fly.io machine**, **Colyseus 0.17 transport**.

  Scale: ✅ aligns / ⚠️ partial / ❌ misaligned.

  | Constraint | Minecraft Java Edition | Quake / Tribes lineage | Current REBNO (pre-06.7) | REBNO post-06.7 (recommended) |
  |------------|------------------------|------------------------|--------------------------|-------------------------------|
  | **Trust model** | Client-trusted position; server stores; "Synchronize Player Position" corrective backstop `[CITED: minecraft.wiki/w/Java_Edition_protocol/Packets]` | Server-authoritative simulation; client predicts, server reconciles `[CITED: fabiensanglard.net/quake3/network.php]` | Server-authoritative simulation; client predicts, server-reconciler-snaps when >22 px divergence `[VERIFIED: reconciler.ts:21]` | Client-trusted (matches Minecraft model). No fall-trigger correction in 06.7 |
  | **Wire shape** | Absolute position: `{X, Y, Z, on_ground}` + variants for rotation. Doubles for coordinates (3×8=24 B per packet) `[CITED: minecraft.wiki]` | Compact input commands + delta-compressed state from old baseline `[CITED: ra.is/unlagged/network.html]` | Axes-only `{seq, axes:{x,y}, buttons_down/up, monotonic_at_ms}` — event-driven, NOT every-tick `[VERIFIED: cInputSchema in intents.ts]` | Absolute position int16: 18 B payload (2+2+2+2+1+1+4+4) |
  | **Cadence** | 20 Hz (server tick rate); client sends position every tick under normal play `[CITED: minecraft.wiki/w/Tick — "20 ticks per second"]` | ~20-30 Hz client snapshots; server snapshots variable | Event-driven on keydown/keyup transitions + 15 s heartbeat — NO per-tick send `[VERIFIED: input-dispatcher.ts:26]` | **30 Hz steady, regardless of motion** (D-06) — matches BNO native tick rate; the heartbeat IS implicit |
  | **Dropped-packet tolerance** | ✅ Excellent — every packet is self-describing; missed packet recovered by next | ⚠️ Quake3 delta-compresses against arbitrary old snapshot; loss recovery is per-snapshot ack `[CITED: fabiensanglard.net/quake3/network.php]` | ❌ Poor — the held-axes map persists state across ticks, but a dropped keyup leaves player walking until heartbeat re-affirms (15 s). Operator UAT 2026-05-16 confirmed "hitching" `[VERIFIED: SEED §Problem]` | ✅ Excellent — absolute-position-per-tick means single packet loss is one missed frame at 30 Hz = 33 ms — invisible. TCP order + Colyseus reliability means packet loss is the connection drop, which SRV-06 grace already handles |
  | **Predictable movement** | ✅ Client owns its position — no rubber-banding under normal play | ⚠️ Reconciler snap if prediction diverges enough; visible in fast-paced FPS | ❌ Failing today — reconciler snap on diagonal-stop produces ~10 px shift `[VERIFIED: SEED §Problem]` | ✅ No reconciler-snap path for self-player; visual position = predictor output, which never reverts |
  | **50 CCU bandwidth** | N/A directly comparable (different scale per server) | Per-server typical 32 CCU; bandwidth scales linearly | Low — event-driven keeps c2s sparse | ~37 kbps down per client (50 peers × 30 Hz × ~25 B per peer); ~15 Mbps aggregate at 50×50. Fly.io shared-cpu-1x handles trivially |
  | **Single Fly.io machine** | ✅ N/A — Minecraft uses one process per server | ✅ Quake3 uses one machine | ✅ Already deployed | ✅ No change — broadcast is still one fan-out per `setPatchRate` tick |
  | **Colyseus 0.17 transport** | N/A (Minecraft is TCP + custom protocol) | N/A (Quake3 is UDP) | ✅ Already configured `[VERIFIED: RebnoRoom.ts:177-178]` | ✅ Same channel (`onMessage('position_update')`) + same state-diff broadcast — zero transport changes |
  | **Anti-cheat posture** | Strong — server force-corrects via `Synchronize Player Position` clientbound | Strong — full simulation authority | Strong — full server authority over x/y | **Zero (D-11)** — accepted risk on pre-launch trusted operator pool |
  | **Implementation cost** | Reference model only — not directly implementable | Heavyweight — full simulation symmetry required | Already shipped | Modest refactor — new handler + handler-side validator + reconciler-no-op + tick-loop trim |

  **Score summary:**
  - **Minecraft model** is the closest aligned reference for REBNO's pre-launch posture: client owns position, server stores it. ✅ for everything that matters.
  - **Quake lineage** is what REBNO is moving AWAY from — it's overkill for a 50 CCU walking-chat game and the reconciler snap is the SOURCE of operator-reported pain.
  - **Current REBNO** scores ❌ on predictable-movement and packet-loss tolerance — both motivating bugs in SEED.

  ## Dropped-Packet Tolerance Analysis (D-16)

  **Question:** how often is a missed `position_update` survivable for the remote-player path? Do we need server-side extrapolation between packets?

  **Finding (HIGH confidence):** No server-side extrapolation needed. Three converging reasons:

  ### 1. Colyseus runs over TCP — there is no "missed packet" in the UDP sense

  `[VERIFIED: WebSearch — Colyseus FAQ + docs.colyseus.io/server/transport/ws]` Colyseus's default transport is WebSocket (TCP). TCP guarantees:
  - **In-order delivery** — frames arrive in send order
  - **Reliable delivery** — frames are retransmitted on loss at the transport layer
  - **No reordering** — application sees a strictly ordered stream

  The only "packet loss" possible from the application's perspective is the connection itself dropping, which already triggers Colyseus's `onLeave` → SRV-06 10 s reconnect grace. During that grace window the player's position is frozen at last-known; this is the correct behavior and matches operator UAT expectations (a dropped player visually pauses, doesn't extrapolate into walls).

  ### 2. 30 Hz absolute position is self-healing by construction

  `[VERIFIED: REQ-SRV-05 in REQUIREMENTS.md + accumulator.ts:8]` Each position_update is fully self-describing — no deltas, no baseline references, no acks required. If frame N is somehow delayed (TCP retransmit blip), frame N+1 lands 33 ms later (at 30 Hz) and contains the same data type — the latest absolute position. The visual effect on the remote viewer: one frame's pause, then jump to current. At 33 ms intervals this is invisible to human perception.

  Compare with Quake 3's delta-compressed snapshots `[CITED: ra.is/unlagged/network.html]`, which can stall ALL subsequent decodes until the baseline arrives — REBNO's model is more robust here.

  ### 3. The remote-extrapolator already exists for the 30 ms gap case

  `[VERIFIED: packages/protocol/src/state.ts:31-32 + ADR 0007 cited there]` `PlayerState.axis_x_held` and `axis_y_held` are broadcast every tick today. The client-side extrapolator uses these to smooth between snapshots (250 ms cap per ADR 0007). With position_update at 30 Hz, gap between consumed snapshots is normally 33 ms; the extrapolator only kicks in when a snapshot is genuinely delayed. The 250 ms cap means even a worst-case TCP-retransmit-spike (~150 ms) is bounded.

  **The new position_update fields populate the same PlayerState slots.** `vx` and `vy` from D-05 land in `PlayerState.vx`/`vy` and the existing extrapolator can incorporate them directly. No new client logic required.

  ### Recommendation
  - **Broadcast cadence:** `setPatchRate(TICK_MS=50)` (current value). At 30 Hz client send + 20 Hz server broadcast, every client snapshot reflects ≤ 50 ms-old data. Acceptable.
  - **No downsampling.** Concrete bandwidth budget: 50 CCU × 30 Hz × ~25 B msgpackr-encoded PlayerState diff per peer = ~37 kbps down per client; aggregate ~15 Mbps over WS. Fly.io shared-cpu-1x is rated 1000s of Mbps NIC throughput — not a constraint.
  - **Defer optimization** until a Phase 7 60 CCU+ stress test demands it. Premature.

  ## Recommended `position_update` Wire Schema

  ### TypeScript shape (zod, JSON over Colyseus channel)

  ```typescript
  // packages/protocol/src/intents.ts — ADD after cSetFacingSchema
  export const cPositionUpdateSchema = z.object({
    type: z.literal('position_update'),
    // Int16 quantized — D-07. Range [-32768, 32767] safely covers BNCentral
    // 8000×6400 px and any room ≤ ~30000 px. Snap-rounded by client (Math.round).
    x: z.number().int().min(-32768).max(32767),
    y: z.number().int().min(-32768).max(32767),
    // BNO instant-set/instant-stop model means vx/vy are typically -5..+5 px/tick.
    // Int16 range trivially holds this with headroom for any future faster items.
    vx: z.number().int().min(-32768).max(32767),
    vy: z.number().int().min(-32768).max(32767),
    // Facing — D-10. Same enum as cSetFacingSchema (legacy compat).
    facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    // anim_state — D-08. Packed 1-byte enum (see byte layout below).
    anim_state: z.number().int().min(0).max(255),
    // seq — keep per Pattern 4. Per-account, monotonic.
    seq: z.number().int().min(0),
    // monotonic_at_ms — 06.4 D-58c precedent; staleness detection without trusting wall-clock.
    monotonic_at_ms: z.number().nonnegative(),
  }).strict();
  ```

  ### Byte layout (binary packing — if Phase 7 demands further compression)

  | Field | Type | Size | Range | Notes |
  |-------|------|------|-------|-------|
  | `x` | int16 LE | 2 B | -32 768..32 767 | Pixel-quantized; client `Math.round()` before send |
  | `y` | int16 LE | 2 B | -32 768..32 767 | Same |
  | `vx` | int16 LE | 2 B | -32 768..32 767 | Typically -5..+5 (RUN_SPEED) |
  | `vy` | int16 LE | 2 B | -32 768..32 767 | Same |
  | `facing` | u8 | 1 B | 0..7 | Octant index (see enum below) |
  | `anim_state` | u8 | 1 B | 0..255 | Packed (see below) |
  | `seq` | u32 LE | 4 B | 0..4 294 967 295 | Per-account monotonic |
  | `monotonic_at_ms` | u32 LE | 4 B | 0..~50 days | Wraps roughly every 50 days — acceptable for a 06.4 D-58c precedent value |
  | **Total payload** | | **18 B** | | |

  **Wire-frame overhead:** Colyseus message-channel framing adds ~10-20 B (channel name "position_update" + msgpackr keyed-object header). End-to-end frame size ~30-40 B. At 30 Hz × 50 CCU upstream that's ~50-60 kbps per server NIC for c2s; trivial.

  ### facing enum (octant index)

  ```typescript
  export const FACING_OCTANT: Record<Direction, number> = {
    R:  0,  // 0°
    UR: 1,  // 45°
    U:  2,  // 90°
    UL: 3,  // 135°
    L:  4,  // 180°
    DL: 5,  // 225°
    D:  6,  // 270°  ← BNO spawn default
    DR: 7,  // 315°
  };
  ```

  ### anim_state byte layout (packed 1-byte enum)

  ```
  bit 7  6  5  4  3  2  1  0
         |reserved | |R| |octant|
                    │   └── 3 bits: facing octant 0..7 (mirrors `facing` field — see note)
                    └── bit 3: 1 = running, 0 = standing
         └── bits 4-7: RESERVED for future poses (jump=0x10, hit=0x20, etc.)
  ```

  Mapped values for the 16 base poses:

  | Pose | Byte | Description |
  |------|------|-------------|
  | `STAND_D`  | `0x06` | facing=D (6), running=0 |
  | `STAND_DR` | `0x07` | facing=DR (7), running=0 |
  | `STAND_R`  | `0x00` | facing=R (0), running=0 |
  | `STAND_UR` | `0x01` | facing=UR (1), running=0 |
  | `STAND_U`  | `0x02` | facing=U (2), running=0 |
  | `STAND_UL` | `0x03` | facing=UL (3), running=0 |
  | `STAND_L`  | `0x04` | facing=L (4), running=0 |
  | `STAND_DL` | `0x05` | facing=DL (5), running=0 |
  | `RUN_D`    | `0x0E` | facing=D (6), running=1 (0x08) |
  | `RUN_DR`   | `0x0F` | facing=DR (7), running=1 |
  | `RUN_R`    | `0x08` | facing=R (0), running=1 |
  | `RUN_UR`   | `0x09` | facing=UR (1), running=1 |
  | `RUN_U`    | `0x0A` | facing=U (2), running=1 |
  | `RUN_UL`   | `0x0B` | facing=UL (3), running=1 |
  | `RUN_L`    | `0x0C` | facing=L (4), running=1 |
  | `RUN_DL`   | `0x0D` | facing=DL (5), running=1 |

  **Note on the facing-vs-anim_state redundancy:** `facing` (top-level field) and `anim_state` bits 0-2 carry the same octant on the wire — this looks redundant. It is intentional per D-10: `facing` = "where the player is pointing" (intent / D-58c set_facing semantics); `anim_state` = "what pose to RENDER right now". The two diverge during HexportIn (`set_sprite_override`) where `facing` may stay at D but rendered pose is the override. Cost of the duplication: 1 byte. Keep both.

  **Reserved bits 4-7** future-proof for jump (`0x10`), hit/stun (`0x20`), watching (`0x30` = set via `set_sprite_override` but mirrored here for state-diff completeness). Up to 16 future poses with zero protocol change.

  ### `set_sprite_override` schema (rare event)

  ```typescript
  // packages/protocol/src/intents.ts — ADD after cPositionUpdateSchema
  export const cSetSpriteOverrideSchema = z.object({
    type: z.literal('set_sprite_override'),
    sprite_id: z.enum([
      'hexport_in', 'hexport_out',
      'tele_in', 'tele_out',
      'ncol_1', 'ncol_2', 'ncol_3', 'ncol_4',
      'jokershell',
      'watching',
      'none',  // clear override
    ]),
    // No coords/velocity — the override is layered atop the position_update stream.
  }).strict();
  ```

  Override semantics (server side): write `PlayerState.sprite_override: string` (new field — additive). Client renderer prefers `sprite_override` over `anim_state` when non-`'none'`. Override is cleared either by the client sending `set_sprite_override(none)` or — once 06.8 lands — by the fall-reset path.

  **This phase ships `set_sprite_override` as a stub** — handler + schema + state field, but no consumer wiring in the renderer (since the listed special poses are all post-MVP). The wiring is forward-compat: when PAR-* phases add Hexport / TeleIn animations, the protocol surface already exists.

  ## Treatment of `seq`, `last_input_seq`, server-side `step()`, broadcast cadence

  ### `seq` / `last_input_seq` — KEEP

  **Recommendation:** Keep both fields exactly as Pattern 4 describes. They are already in the protocol, already on `PlayerState`, and cost 4 B/frame on the wire — trivial for the staleness-detection and reconnect-coherency benefits.

  **Concrete server-side use:**
  ```typescript
  if (parsed.data.seq <= player.last_input_seq) {
    // Out-of-order position_update — discard. Rare under TCP but cheap to defend.
    return;
  }
  player.last_input_seq = parsed.data.seq;
  ```

  ### Server-side `step()` for self-player platform effects — DROP from self-player path

  **Recommendation:** Stop running `step()` for self-player position derivation entirely. Run `step()` ONLY for platforms (REQ-SRV-14) and any future world simulation.

  **Rationale:** Once the client is the authoritative source for `{x, y, vx, vy, facing, anim_state}`, the server has no business running collision/platform-carry logic against the position the client just declared. If the client is on a conveyor platform, the client's `predictor.ts` already runs `step()` locally with the platform state in its WorldState (it reads platforms from the broadcast `PlatformState`). The position the client sends to the server already includes the platform-carry displacement; the server applying it again would double-count.

  **The clean architecture:**
  - Client `predictor.ts` runs `step()` locally with full WorldState (platforms + self + collision grid).
  - Client sends `{x, y, vx, vy, ...}` reflecting the post-step values.
  - Server stores them. Done.
  - Server's own `step()` advances platforms (so the broadcast PlatformState stays fresh) but does NOT touch player position.

  This is the simplest, fewest-moving-parts design and matches Minecraft `[CITED: minecraft.wiki/w/Tick — server tick advances world state, client sends Set Player Position]`.

  **Forward-compat concern (06.8 Phase B):** when the fall trigger lands, the server WILL need a server-side walkable check — but that's a *read-only* check against the client-reported position, NOT a re-derivation. Adding it is orthogonal to running step() for the player.

  ### Broadcast cadence — FULL 30 Hz (= current setPatchRate)

  **Recommendation:** No change. `setPatchRate(TICK_MS=50)` stays — that's 20 Hz broadcast against a 30 Hz client-send. Server stores 30 Hz of updates but only fans out the latest at 20 Hz. Bandwidth-per-client at 50 CCU stays ~37 kbps. Defer downsampling until profiling shows fan-out is a bottleneck.

  Actually — re-examine: the current `setPatchRate(TICK_MS=50)` aligns broadcast with the server tick. With the new model the server tick still runs at 20 Hz (accumulator.ts) for platforms. Keeping `setPatchRate(TICK_MS=50)` is the right call; it aligns broadcast with the server's own simulation cadence and the existing extrapolator's 250 ms cap.

  **Why not match patch rate to the client send rate (30 Hz)?** Two reasons: (1) the server isn't computing player position anymore, so faster broadcast adds no per-tick fidelity; (2) state-diff batching at 50 ms naturally coalesces multiple 33 ms client sends into one network frame downstream, saving bandwidth. Win-win.

  ## Common Pitfalls

  ### Pitfall 1: Reconciler-snap regression

  **What goes wrong:** A future tweak to `reconciler.ts` accidentally re-enables the self-player write path (e.g., someone refactors and removes the early return).

  **Why it happens:** The current self-reconciler is the source of the diagonal-stop drift bug; the temptation to "just fix the threshold" rather than fully no-op is real.

  **How to avoid:** Add an explicit unit test asserting `ReconcileEngine.onServerSnapshot(snap, isSelfPlayer=true)` produces **zero** calls to `sprite.setPosition` and **zero** calls to `sprite.tweenTo`. D-04 calls this out as an acceptance criterion. Pair with a comment in `reconciler.ts` header explaining the invariant.

  **Warning signs:** Operator UAT reports "10 px diagonal shift", "rubber-banding on stop", or "visible jolt on connection blip". Diagnostic ring-buffer `globalThis.__rebno.lastReconcile*` (still useful for debugging remote-path reconciler if retained) will show `lastReconcileReason: 'divergence-threshold'` with self account_id — instant red flag.

  ### Pitfall 2: Stale local prediction after force-resume

  **What goes wrong:** Client reconnects after SRV-06's 10 s grace; server has the LAST-broadcast position (the one BEFORE the disconnect). Client's local predictor has been running the whole time and is now ahead. First position_update post-reconnect tells the server the new (correct) position.

  **Why it's actually fine:** This is the model working as designed. Client is the authoritative source; server adopts client's value on the next position_update. The only visible effect is a single frame where remote viewers see the player at the pre-disconnect position before the post-reconnect update lands — invisible at 30 Hz.

  **How to avoid worry:** Test it explicitly. Server `reconnect.integ.test.ts` should assert: disconnect → reconnect → first position_update overwrites server's stored x/y. (Likely a tiny addition to an existing test.)

  ### Pitfall 3: Position_update spam from a runaway client

  **What goes wrong:** Bug in client send-loop causes 1000+ position_updates per second; server token-bucket drops most but processing each parse still costs CPU.

  **Why it happens:** Client tick loops can degenerate; an infinite loop in render code could starve the tick scheduler and then catch up by firing many in a row.

  **How to avoid:** Reuse the existing token bucket. Add `'position_update'` as a new msg_type to `TokenBucket` per CONTEXT D-22 + Plan 04-08. Suggested budget: refill at 35 tokens/sec, burst capacity 60 (gives 30 Hz + 5/sec headroom for reconnect retransmits, mutes at 60). The cheap-path-before-zod pattern from `onMessageHandlers.ts:69-96` is the right design.

  **Warning signs:** `rate_limit_dropped` log entries spike for `msg_type: 'position_update'`. Operator dashboard should plot this.

  ### Pitfall 4: Forgetting to bump PROTOCOL_VERSION

  **What goes wrong:** Old v3 clients send axes-shape `c2s.input` payloads; new v4 server only handles `position_update`. Mixed-version traffic silently degrades (movement becomes a no-op for the v3 client because nothing writes its position anymore).

  **Why it happens:** The version-bump ritual is documented in `packages/protocol/src/version.ts` history block; easy to skip in the rush of refactoring.

  **How to avoid:** Bump `PROTOCOL_VERSION = 4` and update the history block. The existing `validateAuthFrame` in `intents.ts:285-299` will reject v3 clients with `PROTOCOL_VERSION_MISMATCH` at handshake — exactly the right behavior under D-12 hard-cut deploy. Phase-plan task should call this out explicitly with the deploy-ritual checklist.

  **Warning signs:** Mixed-version is impossible by construction once the server rejects v3; the only way this bug manifests is if the version bump is forgotten. Test: `apps/server/test/protocol-v2-handshake.integ.test.ts` precedent — add a `protocol-v4-handshake` variant.

  ### Pitfall 5: PlayerState schema extension breaks Colyseus delta-encoder

  **What goes wrong:** Adding `anim_state` and `sprite_override` fields to `PlayerState` in `packages/protocol/src/state.ts` requires Colyseus schema-add discipline. `[VERIFIED: state.ts:11-13 comment block]` explicitly warns: "Adding fields to a `@colyseus/schema` class is wire-incompatible with v1 clients per RESEARCH §A5 — the deploy ritual is non-negotiable."

  **Why it happens:** Easy to add a field, forget to bump version, mismatched-schema clients get garbled state.

  **How to avoid:** Same deploy ritual as the PROTOCOL_VERSION bump. The version-history comment in `version.ts` notes "ADR 0007 locks the contract" — read ADR 0007 and ensure entry 4 covers position_update schema. New state.ts fields:
  ```typescript
  @type('number') anim_state: number = 0x06;  // default = STAND_D
  @type('string') sprite_override: string = 'none';
  ```

  ### Pitfall 6: Phaser tween fighting predictor

  **What goes wrong:** Old reconciler called `sprite.tweenTo(x, y, 100)`. If the no-op branch isn't entered correctly, a running tween could still be in-flight at the moment predictor.predictTick writes new sprite.setPosition — visual fight, jerky movement.

  **Why it happens:** Phaser tweens hold a reference to the sprite and apply incrementally; setPosition during a tween creates a race.

  **How to avoid:** When removing the self-reconciler tween path, ALSO ensure GameScene cancels any in-flight tween targeted at the local sprite before the predictor writes. Pattern: `scene.tweens.killTweensOf(localSprite)` in the predictor-write code path. Test surface: `apps/client/src/__test__/game-scene.test.ts`.

  ### Pitfall 7: msgpackr Buffer vs Uint8Array normalization (carry-forward)

  **What goes wrong:** Server emits a position_update derived value via `encodeS2C` (Buffer instance on Node); client expects Uint8Array. The existing `decodeS2C` in `events.ts:80-94` has `normaliseBinary` for `room_layout` — if a new S2C event is added in 06.7 that carries binary, it needs the same normalization.

  **Why it's flagged:** Phase A doesn't add new S2C binary fields (the broadcast goes via Colyseus state-diff, not msgpackr) — but if planning adds an `s2c.position_broadcast` event (rejected option from "Alternatives Considered"), this Pitfall reactivates.

  **How to avoid:** Stick to Colyseus state-diff. If a parallel binary event channel becomes necessary in 06.8, mirror the `normaliseBinary` pattern.

  ### Pitfall 8: Colyseus 0.17 `players.onChange` doesn't fire per-field

  **What goes wrong:** `[VERIFIED: colyseus-client.ts:241-250 comment]` documents this: `players.onChange` only fires on Map key add/remove, NOT on per-field updates. Existing client uses `$(player).onChange(...)` inside `onAdd` to catch per-field deltas. New `anim_state` / `sprite_override` fields fall under the same per-instance handler — no new wiring needed, just verify the existing handler reads them.

  **Warning signs:** Remote avatar plays wrong animation pose despite server logs showing correct `PlayerState.anim_state`. Diagnostic: log `player.anim_state` inside `$(player).onChange` callback in dev builds.

  ## Code Examples

  ### Adding `cPositionUpdateSchema` to the discriminated-union

  ```typescript
  // packages/protocol/src/intents.ts — ADD to c2sSchema
  // Source: pattern from intents.ts:120-127 (existing c2sSchema definition)
  export const c2sSchema = z.discriminatedUnion('type', [
    cAuthSchema,
    cInputSchema,           // KEEP — chat-pause uses pauseMovement → sendInput(0,0); also test harness path
    cChatSendSchema,
    cRoomJoinSchema,
    cHeartbeatSchema,
    cSetFacingSchema,
    cPositionUpdateSchema,   // NEW (06.7)
    cSetSpriteOverrideSchema, // NEW (06.7)
  ]);
  ```

  **Decision call:** keep `cInputSchema`? Yes — for two reasons:
  1. `input-dispatcher.ts:298` `pauseMovement()` sends `{axes: {x:0, y:0}}` to clear server held-input state during chat-input focus. Easier to keep this contract than to remove it.
  2. Test harnesses poke `inputBuffer` directly via `cInputSchema` (per `onMessageHandlers.ts:21-23`). Removing breaks tests.

  **Server-side mechanics:** with the carve-out, the `c2s.input` handler in `onMessageHandlers.ts:104-136` still writes into `heldInputs`, but `tickLoop` no longer reads `heldInputs` for player position. Heldinputs becomes vestigial-but-harmless. Plan-task: leave it for 06.7; remove in a 06.9 cleanup once 06.8 ships.

  ### Position dispatcher (client, every-tick send)

  ```typescript
  // apps/client/src/prediction/position-dispatcher.ts  (NEW FILE)
  // [impl->REQ-CLI-04] [impl->REQ-CLI-08]
  // Source: pattern derived from input-dispatcher.ts:256-286 sendInput
  import type { Room } from '@colyseus/sdk';
  import type { PredictionEngine } from './predictor.js';
  import type { SpriteStateMachine } from '../render/SpriteStateMachine.js';
  import { packAnimState, FACING_OCTANT } from '@rebno/protocol';

  export class PositionDispatcher {
    private nextSeq = 0;
    constructor(
      private readonly room: Pick<Room, 'send'>,
      private readonly prediction: PredictionEngine,
      private readonly getFacing: () => Direction,
      private readonly getIsRunning: () => boolean,
    ) {}

    /** Called once per sim tick (30 Hz) by GameScene.update. */
    sendTick(monotonic_at_ms: number): void {
      const sim = this.prediction.getLocalState();
      const facing = this.getFacing();
      const isRunning = this.getIsRunning();
      this.room.send('position_update', {
        type: 'position_update',
        x: Math.round(sim.x),
        y: Math.round(sim.y),
        vx: Math.round(sim.vx),
        vy: Math.round(sim.vy),
        facing,
        anim_state: packAnimState(facing, isRunning),
        seq: ++this.nextSeq,
        monotonic_at_ms,
      });
    }
  }
  ```

  ### Server tickLoop after the change

  ```typescript
  // apps/server/src/RebnoRoom.ts — MODIFIED tickLoop (compare to existing :1274-1315)
  private tickLoop(realDt: number): void {
    const { newAcc, ticks } = advanceAccumulator(this.accumulator, realDt);
    this.accumulator = newAcc;
    if (realDt > 100 || ticks > 4) {
      log.warn({ event: 'tick_loop_slow', realDt, ticks, roomId: this.roomId, playerCount: this.state.players.size },
        'tick loop slow — event-loop stall suspected');
    }
    for (let i = 0; i < ticks; i++) {
      // Server step() ONLY for platforms (REQ-SRV-14).
      // Player position is no longer derived here — it's written by the
      // position_update handler. Pass an empty inputs map: with the player
      // pass-through change in step.ts (Pattern 2 above), the players map
      // round-trips unchanged.
      const next = step(this.toWorldState(), new Map(), TICK_MS);
      this.applyToColyseusState(next);
    }
  }
  ```

  ## State of the Art

  | Old Approach | Current Approach | When Changed | Impact |
  |--------------|------------------|--------------|--------|
  | Quake3 server-authoritative simulation + reconciler | Minecraft-style client-trusted position + corrective override (the "fall trigger" backstop in 06.8) | Modern small-scale multiplayer (post-~2015) | Predictable feel, simpler server, smaller anti-cheat surface for trusted player pools |
  | Event-driven axes input | Per-tick absolute position streaming | The phase | Single dropped packet recovers naturally; no held-state divergence |
  | Hybrid prediction + threshold-snap reconciler | Pure client-feel for self, server-broadcast for remote | The phase | Removes the diagonal-stop ~10 px drift root cause |

  **Deprecated/outdated (post-06.7):**
  - `apps/client/src/prediction/reconciler.ts` `globalThis.__rebno.lastReconcile*` writes for the self-player — remove cleanly. Operator diagnostic still useful if a remote-snap path is added in 06.8 (it currently isn't called for remotes either, but the symmetry is worth preserving until 06.8 confirms).
  - `DIVERGENCE_THRESHOLD_PX = 22` constant — stays defined but unused. Mark `@deprecated 06.7` in the comment.

  ## Assumptions Log

  | # | Claim | Section | Risk if Wrong |
  |---|-------|---------|---------------|
  | A1 | `[ASSUMED]` Bandwidth at 50 CCU × 30 Hz × 25 B is ~15 Mbps aggregate / ~37 kbps per client | "Standard Stack" + "Dropped-Packet Tolerance" | Low — back-of-envelope is conservative; even if 3× off, still well under Fly.io shared-cpu-1x NIC budget |
  | A2 | `[ASSUMED]` Colyseus state-diff batches multiple 33-ms client sends into one 50-ms broadcast frame downstream | "Broadcast cadence" recommendation | Low — Colyseus docs `[CITED: docs.colyseus.io/state — "only state mutations are broadcasted at every patch"]` confirm property-level batching; the temporal coalescing is implied but not explicitly tested in this session |
  | A3 | `[ASSUMED]` Client `predictor.ts` already incorporates platform-carry into its local step() | "Server-side step() retention" — DROP recommendation | Medium — if `predictor.ts` does NOT consume PlatformState in its local WorldState, then dropping server-side player-position step() leaves the client unable to compute platform-carry locally. **Mitigation:** verify before implementation. Predictor.ts:105-115 builds `prevState.platforms: new Map()` (empty) — so as of today the client does NOT have full platform-carry. This is acceptable for 06.7 (MVP slice has no platforms per CDOC/PAR-03 deferral) but the assumption needs explicit handoff to Phase 7 when platforms ship. Plan-phase should flag this. |
  | A4 | `[ASSUMED]` Token bucket budget of 35 tokens/sec, burst 60 is sufficient for position_update at 30 Hz | "Pitfall 3" | Low — rate-limit values are tunable post-staging; default starting point chosen with 5 Hz headroom |
  | A5 | `[ASSUMED]` PROTOCOL_VERSION bump from 3 → 4 is the right path (vs. additive optional fields) | "Pitfall 4" | Low — wire shape is genuinely changing (new `position_update` handler is the new authoritative source for player position); D-12 hard-cut + version-history convention `[VERIFIED: version.ts:14-22]` makes the bump the established practice |
  | A6 | `[ASSUMED]` Bits 4-7 of `anim_state` reserved for future poses (jump/hit/watching) covers needs through Phase 7 PAR-* | "anim_state byte layout" | Low — 4 reserved bits = 16 future poses; legacy BNO has ~6 special poses tracked in SEED |

  ## Open Questions (RESOLVED)

  1. **A3 — does client-side `predictor.ts` need to start consuming PlatformState?**
     - What we know: predictor.ts:105-115 builds a WorldState with `platforms: new Map()` (empty)
     - What's unclear: when Phase 7 adds platform-bearing rooms, will the position the client SENDS include platform-carry displacement, or will it omit it (causing visual sync to lag platforms)?
     - Recommendation: Plan-phase task should NOT add platform-carry to predictor.ts in 06.7 (MVP has no platforms). Add an explicit cross-reference comment in the predictor + flag this in PAR-03 planning.

  2. **Should `ReconcileEngine` be deleted entirely vs. neutered to a no-op?**
     - What we know: ReconcileEngine.onServerSnapshot is invoked from somewhere in GameScene (likely; need to confirm during planning). The current code already documents (06.4 round-3 comments) that remote players go through PlayerRenderer, not ReconcileEngine.
     - What's unclear: is there ANY caller of ReconcileEngine.onServerSnapshot in active client code post-06.7? If not, the cleanest action is full deletion of the file + test.
     - Recommendation: Planner should grep `apps/client/src` for `ReconcileEngine` and `onServerSnapshot` callers. If zero non-test callers, full deletion is safer than no-op-with-trapdoor. Document the diagnostic-ringbuffer-loss tradeoff.

  3. **Should `c2s.input` and `heldInputs` be removed in 06.7 or deferred to 06.9 cleanup?**
     - What we know: `cInputSchema` + heldInputs are vestigial once position_update lands (server.step() no longer reads heldInputs for player position). `pauseMovement()` chat-focus contract uses cInputSchema.
     - What's unclear: removing immediately requires re-routing the chat-focus contract (probably to a new `c2s.pause_movement` no-op or just dropping it since position_update streaming with vx=vy=0 IS the new "paused" signal).
     - Recommendation: KEEP in 06.7. Cleanup phase (call it 06.9 or fold into 06.8) removes after the new model is operator-validated.

  4. **Should the protocol track a server-broadcast `last_received_at_server_monotonic_ms` for round-trip diagnostics?**
     - What we know: client sends `monotonic_at_ms`; server doesn't echo a corresponding timestamp.
     - What's unclear: would operators benefit from a c2s/server-stamp round-trip diagnostic for staging UAT?
     - Recommendation: defer; no signal from operator that this is needed. Position itself self-validates.

  ## Environment Availability

  Skipped — phase is purely code refactor against existing stack. All dependencies already pinned in `apps/server/package.json` and `apps/client/package.json`; no new tooling required.

  ## Validation Architecture

  ### Test Framework
  | Property | Value |
  |----------|-------|
  | Framework | `vitest` 4.1.5 (server) / 3.2.4 (client) `[VERIFIED: package.json files]` |
  | Config file | Implicit `vitest` config in each app + workspace |
  | Quick run command (server) | `pnpm -C apps/server test` |
  | Quick run command (client) | `pnpm -C apps/client test` |
  | Full suite command | `pnpm -r test` (workspace-recursive) |
  | Integration tests (server) | `pnpm -C apps/server test:integration` |

  ### Phase Requirements → Test Map

  | Req ID | Behavior | Test Type | Automated Command | File Exists? |
  |--------|----------|-----------|-------------------|-------------|
  | REQ-SRV-03 | server `position_update` handler validates with cPositionUpdateSchema (zod strict) and writes to PlayerState | unit | `pnpm -C apps/server test position-update` | Wave 0: NEW `apps/server/test/position-update.test.ts` |
  | REQ-SRV-03 | server `position_update` handler is rate-limited and rejects stale seq | unit | `pnpm -C apps/server test position-update-rate-limit` | Wave 0: NEW (covered in same file) |
  | REQ-SRV-03 | server `set_sprite_override` handler accepts valid sprite_id, writes to PlayerState.sprite_override | unit | `pnpm -C apps/server test sprite-override` | Wave 0: NEW `apps/server/test/sprite-override.test.ts` |
  | REQ-SRV-03 | end-to-end: client sends position_update → server stores → broadcasts → second client sees update | integration | `pnpm -C apps/server test:integration position-update` | Wave 0: NEW `apps/server/test/position-update.integ.test.ts` |
  | REQ-SRV-03 | PROTOCOL_VERSION 3 client rejected at handshake (close 4400) | integration | `pnpm -C apps/server test:integration protocol-v4-handshake` | Wave 0: NEW `apps/server/test/protocol-v4-handshake.integ.test.ts` (mirrors `protocol-v2-handshake.integ.test.ts`) |
  | REQ-SRV-14 | `step()` still advances platform positions (regression — platforms not broken by carve-out) | unit | `pnpm -C packages/game-logic test step.platforms` | ✅ existing `packages/game-logic/test/step.test.ts` — add platform-advance assertion if not present |
  | REQ-CLI-04 | reconciler.onServerSnapshot is a no-op for self-player — sprite.setPosition + sprite.tweenTo never called (D-04 acceptance criterion) | unit | `pnpm -C apps/client test reconciler-self-noop` | Wave 0: NEW assertion added to `apps/client/src/__test__/reconciler.test.ts` |
  | REQ-CLI-04 | PositionDispatcher sends position_update at 30 Hz on tick, correctly populated from PredictionEngine + SpriteStateMachine output | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW `apps/client/src/__test__/position-dispatcher.test.ts` |
  | REQ-CLI-04 | predictor continues to drive local sprite position on every tick (regression — local feel preserved) | unit | `pnpm -C apps/client test prediction` | ✅ existing `apps/client/src/__test__/prediction.test.ts` |
  | REQ-CLI-04 | anim_state packing — 16 base poses round-trip pack→unpack | unit | `pnpm -C packages/protocol test anim-state` | Wave 0: NEW `packages/protocol/test/anim-state.test.ts` |
  | REQ-CLI-08 | two-player smoke: A moves, B sees A move smoothly (no rubber-band, no diagonal-stop drift, no hitching on simulated packet delay) | e2e | `pnpm -C apps/client test:e2e two-player-movement` | partial — covered by existing CLI-08 e2e; ADD assertion for diagonal-stop drift regression |
  | REQ-CLI-08 | manual operator UAT on staging — diagonal-stop drift bug closed; dropped-packet hitching bug closed | manual-only | (operator session — captures in `06.7-HUMAN-UAT.md`) | Wave 0: NEW operator UAT script |

  **Manual-only justification (REQ-CLI-08 row 2):** the original two motivating bugs (D-02) are subjective movement-feel issues caught by the operator's eye, not by automated thresholds. The automated CLI-08 e2e covers structural correctness (positions update, broadcast happens, two players see each other); operator UAT closes the feel question. Standard for 06.* operator-UAT-gated phases (see `04-HUMAN-UAT.md` precedent).

  ### Sampling Rate
  - **Per task commit:** `pnpm -C <affected-package> test` (server / client / protocol / game-logic — only the changed one)
  - **Per wave merge:** `pnpm -r test` (workspace-recursive) + `pnpm -r typecheck` + `pnpm trace:check`
  - **Phase gate:** Full suite green + `pnpm -C apps/server test:integration` green + `pnpm -C apps/client test:e2e` green + operator UAT signed off before `/gsd-verify-work`

  ### Wave 0 Gaps
  - [ ] `apps/server/test/position-update.test.ts` — unit tests for cPositionUpdateSchema.safeParse rejection paths + handler PlayerState writes — covers REQ-SRV-03
  - [ ] `apps/server/test/position-update.integ.test.ts` — two-client roundtrip integration test — covers REQ-SRV-03
  - [ ] `apps/server/test/sprite-override.test.ts` — handler + schema — covers REQ-SRV-03
  - [ ] `apps/server/test/protocol-v4-handshake.integ.test.ts` — version-bump rejection — covers REQ-SRV-03
  - [ ] `apps/client/src/__test__/position-dispatcher.test.ts` — dispatcher unit — covers REQ-CLI-04
  - [ ] `packages/protocol/test/anim-state.test.ts` — packAnimState/unpackAnimState round-trip — covers REQ-CLI-04
  - [ ] `apps/client/src/__test__/reconciler.test.ts` — ADD self-noop assertion (file exists; new test case) — covers REQ-CLI-04 D-04 acceptance
  - [ ] Update existing `apps/client/src/__test__/prediction.test.ts` — confirm predictor still drives sprite (regression guard) — covers REQ-CLI-04
  - [ ] `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — operator UAT script covering diagonal-stop drift + simulated-packet-delay hitching — covers REQ-CLI-08

  *Framework install: none — `vitest` already pinned and runs in every package.*

  ## Security Domain

  > Phase-relevant ASVS categories. REBNO's `security_enforcement` is implicit-on (no explicit `false` in config.json `workflow` block).

  ### Applicable ASVS Categories

  | ASVS Category | Applies | Standard Control |
  |---------------|---------|-----------------|
  | V2 Authentication | yes | Better-Auth + argon2id (unchanged — auth runs at room onAuth before any position_update can land) |
  | V3 Session Management | yes | Server-tagged identity from `client.auth.account_id`; wire-side identity REJECTED at zod parse (`.strict()`) — same pattern as existing `cInputSchema` |
  | V4 Access Control | yes | Server checks `auth` exists before accepting any position_update; rate-limit gate (token bucket) per (account_id, msg_type) |
  | V5 Input Validation | **yes — primary** | zod `cPositionUpdateSchema.strict()` validates every field, including int16 range on x/y/vx/vy, enum on facing, byte range on anim_state, monotonic on seq. Same pattern as existing schemas. |
  | V6 Cryptography | no | No new crypto in 06.7; manifest signatures for room layouts unchanged |

  ### Known Threat Patterns for {Colyseus 0.17 + Phaser 3.90 + zod stack}

  | Pattern | STRIDE | Standard Mitigation |
  |---------|--------|---------------------|
  | Forged sender identity on the wire (client claims to be another account) | Spoofing | `.strict()` on cPositionUpdateSchema rejects any extra fields including `account_id`/`pid`; server tags identity from `client.auth.account_id` (existing pattern, `onMessageHandlers.ts:107-110`) |
  | Malicious zod payload (huge / deeply-nested) | DoS | int16 bounds + 1-byte enum bounds + bounded string for facing enum + non-negative for seq cap memory cost per parse to bytes |
  | Position-update spam | DoS | Token bucket per (account_id, 'position_update') — same pattern as existing rate limits, suggested 35 tokens/sec / burst 60 |
  | Position spoofing for advantage (teleport) | Tampering | **Accepted in 06.7 per D-11** — fall-trigger reset is 06.8 mitigation; trusted operator pool in interim |
  | Stale / out-of-order updates from TCP reorder | Tampering (mild) | seq monotonic check: `if (parsed.data.seq <= player.last_input_seq) return;` |
  | `anim_state` corruption causing client renderer crash | Tampering | int range cap (0..255) + client-side enum-table lookup (already a defensive pattern in SpriteStateMachine: unknown frame keys would fall through to STAND_D) |

  **Threat acceptance note (D-11):** position spoofing is acknowledged as accepted risk. The threat-modeling row above is for documentation continuity; CLAUDE.md Hard Rule 1 carve-out per D-13 codifies the acceptance.

  ## Sources

  ### Primary (HIGH confidence)
  - **Codebase (this session):** apps/server/src/RebnoRoom.ts, apps/server/src/onMessageHandlers.ts, apps/client/src/prediction/reconciler.ts, apps/client/src/prediction/predictor.ts, apps/client/src/prediction/input-dispatcher.ts, apps/client/src/net/colyseus-client.ts, packages/protocol/src/{intents,state,events,version}.ts, packages/game-logic/src/{step,sprite-state-machine,constants,accumulator}.ts, apps/client/src/render/SpriteStateMachine.ts, package.json files
  - **CONTEXT.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md` — all D-01..D-16 decisions
  - **SEED.md** at `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-SEED.md` — original problem statement

  ### Secondary (MEDIUM confidence)
  - [Minecraft Wiki: Java Edition protocol/Packets](https://minecraft.wiki/w/Java_Edition_protocol/Packets) — Set Player Position, Set Player Position and Rotation, Synchronize Player Position packets (verified field layouts; byte estimates; clientbound vs serverbound)
  - [Minecraft Wiki: Java Edition protocol/FAQ](https://minecraft.wiki/w/Java_Edition_protocol/FAQ) — protocol overview
  - [Minecraft Wiki: Tick](https://minecraft.wiki/w/Tick) — "20 ticks per second" canonical rate; client position updates "on each tick"
  - [Quake 3 Source Code Review: Network Model (Fabien Sanglard)](https://fabiensanglard.net/quake3/network.php) — client-prediction + server-reconcile + delta-compression mechanics (Quake lineage comparator)
  - [Quake 3 Networking Primer (ra.is/unlagged)](https://www.ra.is/unlagged/network.html) — delta-compression and packet-loss recovery details
  - [Wikipedia: Client-side prediction](https://en.wikipedia.org/wiki/Client-side_prediction) — sequence-number reconciliation pattern (Pattern 4)
  - [Colyseus docs: State Synchronization](https://docs.colyseus.io/state) — property-level delta encoding; `setPatchRate` default 50ms
  - [Colyseus docs: WebSocket (Default) transport](https://docs.colyseus.io/server/transport/ws) — TCP-based reliable + ordered delivery confirmation
  - [Colyseus docs: Best Practices](https://docs.colyseus.io/state/best-practices) — delta-compression and binary-encoding overview

  ### Tertiary (LOW confidence — flagged for validation)
  - No tertiary-only sources used. Every claim is supported either by codebase grep (HIGH) or by at least one MEDIUM source corroborated against another.

  ## Metadata

  **Confidence breakdown:**
  - **Standard stack:** HIGH — every version pinned in this session against `package.json` files; no new dependencies
  - **Architecture:** HIGH — patterns derived from existing 06.4 D-58c set_facing precedent and verified Minecraft analog
  - **Wire schema:** HIGH — int16 bounds + zod patterns mirror existing `cInputSchema` and `cSetFacingSchema` exactly
  - **Pitfalls:** HIGH — direct from operator UAT logs (SEED), version-bump history (`version.ts` comments), Phaser tween idioms (in-file comments)
  - **Broadcast-cadence recommendation:** MEDIUM — bandwidth math is back-of-envelope; will validate against Fly.io staging telemetry post-deploy
  - **Server-side `step()` retention:** MEDIUM — recommendation is sound but A3 (platform-carry assumption) needs explicit handoff to Phase 7 planner

  **Research date:** 2026-05-17
  **Valid until:** 2026-06-17 (30 days — stable stack, no breaking version pins to track)

  ### Validation Strategy (VALIDATION.md)
  ---
  phase: 06.7
  slug: network-model-client-trust-fall-trigger
  status: draft
  nyquist_compliant: true
  wave_0_complete: false
  created: 2026-05-17
  ---

  # Phase 06.7 — Validation Strategy

  > Per-phase validation contract for feedback sampling during execution.
  > Substance extracted from `06.7-RESEARCH.md` §Validation Architecture (line 747) per
  > plan-checker iteration 1 blocker fix.

  ---

  ## Test Infrastructure

  | Property | Value |
  |----------|-------|
  | **Framework** | `vitest` 4.1.5 (server) / 3.2.4 (client) `[VERIFIED: package.json files]` |
  | **Config file** | Implicit `vitest` config in each app + workspace |
  | **Quick run command (server)** | `pnpm -C apps/server test` |
  | **Quick run command (client)** | `pnpm -C apps/client test` |
  | **Full suite command** | `pnpm -r test` (workspace-recursive) |
  | **Integration tests (server)** | `pnpm -C apps/server test:integration` |
  | **Estimated runtime** | ~45s per-package quick run; ~3min workspace-recursive |

  ---

  ## Sampling Rate

  - **After every task commit:** `pnpm -C <affected-package> test` (server / client / protocol / game-logic — only the changed one)
  - **After every plan wave:** `pnpm -r test` + `pnpm -r typecheck` + `pnpm trace:check`
  - **Before `/gsd-verify-work`:** Full suite green + `pnpm -C apps/server test:integration` green + `pnpm -C apps/client test:e2e` green + operator UAT signed off
  - **Max feedback latency:** < 60s for quick run; < 5min for wave gate

  ---

  ## Per-Task Verification Map (Phase Requirements → Test Map)

  | Req ID | Behavior | Test Type | Automated Command | File Exists? |
  |--------|----------|-----------|-------------------|-------------|
  | REQ-SRV-03 | server `position_update` handler validates with cPositionUpdateSchema (zod strict) and writes to PlayerState | unit | `pnpm -C apps/server test position-update` | Wave 0: NEW `apps/server/test/position-update.test.ts` |
  | REQ-SRV-03 | server `position_update` handler is rate-limited and rejects stale seq | unit | `pnpm -C apps/server test position-update-rate-limit` | Wave 0: NEW (covered in same file) |
  | REQ-SRV-03 | server `set_sprite_override` handler accepts valid sprite_id, writes to PlayerState.sprite_override | unit | `pnpm -C apps/server test sprite-override` | Wave 0: NEW `apps/server/test/sprite-override.test.ts` |
  | REQ-SRV-03 | end-to-end: client sends position_update → server stores → broadcasts → second client sees update | integration | `pnpm -C apps/server test:integration position-update` | Wave 0: NEW `apps/server/test/position-update.integ.test.ts` |
  | REQ-SRV-03 | PROTOCOL_VERSION 3 client rejected at handshake (close 4400) | integration | `pnpm -C apps/server test:integration protocol-v4-handshake` | Wave 0: NEW `apps/server/test/protocol-v4-handshake.integ.test.ts` (mirrors `protocol-v2-handshake.integ.test.ts`) |
  | REQ-SRV-14 | `step()` still advances platform positions (regression — platforms not broken by carve-out) | unit | `pnpm -C packages/game-logic test step.platforms` | ✅ existing `packages/game-logic/test/step.test.ts` — add platform-advance assertion if not present |
  | REQ-CLI-04 | reconciler.onServerSnapshot is a no-op for self-player — sprite.setPosition + sprite.tweenTo never called (D-04 acceptance criterion) | unit | `pnpm -C apps/client test reconciler-self-noop` | Wave 0: NEW assertion added to `apps/client/src/__test__/reconciler.test.ts` |
  | REQ-CLI-04 | PositionDispatcher sends position_update at 30 Hz on tick, correctly populated from PredictionEngine + SpriteStateMachine output | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW `apps/client/src/__test__/position-dispatcher.test.ts` |
  | REQ-CLI-04 | predictor continues to drive local sprite position on every tick (regression — local feel preserved) | unit | `pnpm -C apps/client test prediction` | ✅ existing `apps/client/src/__test__/prediction.test.ts` |
  | REQ-CLI-04 | anim_state packing — 16 base poses round-trip pack→unpack | unit | `pnpm -C packages/protocol test anim-state` | Wave 0: NEW `packages/protocol/test/anim-state.test.ts` |
  | REQ-CLI-04 | **D-06 idle cadence guard** — PositionDispatcher.sendTick emits valid payload when `getLocalState()` returns `{vx:0, vy:0}` (idle player still sends 30 Hz) | unit | `pnpm -C apps/client test position-dispatcher` | Wave 0: NEW assertion in `apps/client/src/__test__/position-dispatcher.test.ts` (added per plan-checker iter 1 warning #3) |
  | REQ-CLI-08 | two-player smoke: A moves, B sees A move smoothly (no rubber-band, no diagonal-stop drift, no hitching on simulated packet delay) | e2e | `pnpm -C apps/client test:e2e two-player-movement` | partial — covered by existing CLI-08 e2e; ADD assertion for diagonal-stop drift regression |
  | REQ-CLI-08 | manual operator UAT on staging — diagonal-stop drift bug closed; dropped-packet hitching bug closed; idle-cadence ~30 Hz visible in DevTools Network | manual-only | (operator session — captures in `06.7-HUMAN-UAT.md`) | Wave 0: NEW operator UAT script |

  **Manual-only justification (REQ-CLI-08 row 2):** the two original motivating bugs (D-02) are subjective movement-feel issues caught by the operator's eye, not by automated thresholds. The automated CLI-08 e2e covers structural correctness (positions update, broadcast happens, two players see each other); operator UAT closes the feel question. Standard for 06.* operator-UAT-gated phases (see `04-HUMAN-UAT.md` precedent).

  ---

  ## Wave 0 Requirements

  - [ ] `apps/server/test/position-update.test.ts` — unit tests for `cPositionUpdateSchema.safeParse` rejection paths + handler `PlayerState` writes → REQ-SRV-03
  - [ ] `apps/server/test/position-update.integ.test.ts` — two-client roundtrip integration test → REQ-SRV-03
  - [ ] `apps/server/test/sprite-override.test.ts` — handler + schema → REQ-SRV-03
  - [ ] `apps/server/test/protocol-v4-handshake.integ.test.ts` — version-bump rejection → REQ-SRV-03
  - [ ] `apps/client/src/__test__/position-dispatcher.test.ts` — dispatcher unit (incl. D-06 idle-cadence assertion) → REQ-CLI-04
  - [ ] `packages/protocol/test/anim-state.test.ts` — `packAnimState`/`unpackAnimState` round-trip → REQ-CLI-04
  - [ ] `apps/client/src/__test__/reconciler.test.ts` — ADD self-noop assertion (file exists; new test case) → REQ-CLI-04 D-04 acceptance
  - [ ] Update existing `apps/client/src/__test__/prediction.test.ts` — confirm predictor still drives sprite (regression guard) → REQ-CLI-04
  - [ ] `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` — operator UAT script covering diagonal-stop drift + simulated-packet-delay hitching + idle-cadence visibility → REQ-CLI-08

  *Framework install: none — `vitest` already pinned and runs in every package.*

  ---

  ## Manual-Only Verifications

  | Behavior | Requirement | Why Manual | Test Instructions |
  |----------|-------------|------------|-------------------|
  | Diagonal-stop drift closed (~10 px horizontal shift on stop no longer observed) | REQ-CLI-08 (D-02) | Movement-feel; needs human eye | Operator UAT Test 1 — walk diagonally on staging, release keys, observe no shift |
  | Dropped-packet hitching closed | REQ-CLI-08 (D-02) | Network-jitter perception | Operator UAT Test 2 — simulated packet delay via DevTools throttling; observe smooth interpolation |
  | Idle cadence visible at ~30 Hz | REQ-CLI-04 (D-06) | Wire-cadence visual confirmation | Operator UAT Test 6 (added per plan-checker iter 1 warning #3) — idle player; open DevTools Network panel; observe ~30 `position_update` frames/sec |
  | Rollback drill via `/gsd-undo` | REQ-CLI-08 (D-12, D-14) | Disaster-recovery rehearsal | Operator UAT Test 5 — run `/gsd-undo` on the 06.7 phase commits, verify staging reverts to 06.6 behavior |

  ---

  ## Validation Sign-Off

  - [x] All tasks have `<automated>` verify or Wave 0 dependencies
  - [x] Sampling continuity: no 3 consecutive tasks without automated verify
  - [x] Wave 0 covers all MISSING references
  - [x] No watch-mode flags
  - [x] Feedback latency < 60s for quick run
  - [x] `nyquist_compliant: true` set in frontmatter

  **Approval:** pending (plan-checker re-verification after iter 1 revision)

  ### Pattern Map (PATTERNS.md)
  # Phase 06.7: Network model — client-trust + server illegal-position fall trigger — Pattern Map

  **Mapped:** 2026-05-17
  **Files analyzed:** 13 (5 modified + 6 new + 2 doctrine)
  **Analogs found:** 13 / 13

  ## File Classification

  | New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
  |-------------------|------|-----------|----------------|---------------|
  | `packages/protocol/src/intents.ts` | wire schema (MODIFY: add `cPositionUpdateSchema`, `cSetSpriteOverrideSchema`, `FACING_OCTANT`, `packAnimState`/`unpackAnimState`) | request-response (c2s) | `cSetFacingSchema` @ same file lines 93-98 + `cInputSchema` @ lines 45-59 | exact |
  | `packages/protocol/src/state.ts` | schema (MODIFY: add `anim_state` + `sprite_override` PlayerState fields) | event-driven (server→client state-diff) | `facing` field @ same file line 52 (06.4 D-58c precedent) | exact |
  | `packages/protocol/src/version.ts` | config (MODIFY: bump 3 → 4) | none | History block @ same file lines 14-22 | exact |
  | `apps/server/src/onMessageHandlers.ts` | server handler (MODIFY: add `position_update` + `set_sprite_override` handlers; extend rate-limit msg_type union) | request-response | `set_facing` handler @ same file lines 264-301 | exact |
  | `apps/server/src/RebnoRoom.ts` | room tick (MODIFY: tickLoop skips player input application; step() runs platforms only) | event-driven (per-tick) | Current tickLoop @ same file lines 1274-1315 | exact (in-place edit) |
  | `apps/client/src/prediction/position-dispatcher.ts` | client dispatcher (NEW) | request-response (per-tick send) | `InputDispatcher.sendInput` @ `apps/client/src/prediction/input-dispatcher.ts` lines 256-286 | exact |
  | `apps/client/src/prediction/reconciler.ts` | reconciler (MODIFY: self-player no-op via `isSelfPlayer` flag; remove globalThis ring-buffer for self) | event-driven (server-snapshot) | Current `ReconcileEngine.onServerSnapshot` @ same file lines 52-122 | exact (in-place edit) |
  | `apps/client/src/net/colyseus-client.ts` | client dispatcher wiring (MODIFY: instantiate PositionDispatcher; remove ReconcileEngine self-snapshot wiring) | event-driven | `bindHandlers` @ same file lines 237-258 | exact (in-place edit) |
  | `apps/server/test/position-update.test.ts` | test (NEW: zod + handler unit) | request-response | `apps/server/test/d58c-set-facing.integ.test.ts` + `packages/protocol/test/intents.test.ts` | exact |
  | `apps/server/test/position-update.integ.test.ts` | test (NEW: two-client integ) | request-response | `apps/server/test/event-driven-input.integ.test.ts` | exact |
  | `apps/server/test/sprite-override.test.ts` | test (NEW: handler + schema) | request-response | `apps/server/test/d58c-set-facing.integ.test.ts` | role-match |
  | `apps/server/test/protocol-v4-handshake.integ.test.ts` | test (NEW: version-bump rejection) | request-response | `apps/server/test/protocol-v2-handshake.integ.test.ts` | exact |
  | `apps/client/src/__test__/position-dispatcher.test.ts` | test (NEW: dispatcher unit) | request-response | `apps/client/src/__test__/input-dispatcher.test.ts` | exact |
  | `apps/client/src/__test__/reconciler.test.ts` | test (ADD case: D-04 self no-op assertion) | event-driven | Existing same file lines 52-93 | exact (additive case) |
  | `packages/protocol/test/anim-state.test.ts` | test (NEW: pack/unpack round-trip) | none | `packages/protocol/test/intents.test.ts` | role-match |
  | `packages/protocol/test/state.test.ts` | test (ADD case: anim_state + sprite_override defaults; bump PROTOCOL_VERSION === 4) | none | Existing same file lines 10-30 | exact (in-place edit) |
  | `CLAUDE.md` | doctrine (MODIFY: Hard Rule 1 movement carve-out per D-13) | none | Hard Rule 1 prose @ same file | exact |
  | `.planning/phases/06.7-.../06.7-HUMAN-UAT.md` | operator UAT script (NEW) | none | `.planning/phases/06.1-.../06.1-HUMAN-UAT.md` | role-match |

  ## Pattern Assignments

  ### `packages/protocol/src/intents.ts` (wire schema, c2s)

  **Analog:** `packages/protocol/src/intents.ts` itself — copy `cSetFacingSchema` and `cInputSchema` shape

  **Schema definition pattern** (lines 93-98, `cSetFacingSchema`):
  ```typescript
  export const cSetFacingSchema = z
    .object({
      type: z.literal('set_facing'),
      facing: z.enum(['D', 'DR', 'R', 'UR', 'U', 'UL', 'L', 'DL']),
    })
    .strict();
  ```

  **Required-fields pattern** (lines 45-59, `cInputSchema` — keep `seq` + `monotonic_at_ms` precedent):
  ```typescript
  export const cInputSchema = z
    .object({
      type: z.literal('input'),
      seq: z.number().int().min(0),
      axes: z.object({ /* ... */ }).strict(),
      buttons_down: z.number().int().min(0).max(0xffff),
      buttons_up: z.number().int().min(0).max(0xffff),
      monotonic_at_ms: z.number().nonnegative(),
    })
    .strict();
  ```

  **Discriminated-union registration** (lines 120-127):
  ```typescript
  export const c2sSchema = z.discriminatedUnion('type', [
    cAuthSchema,
    cInputSchema,
    cChatSendSchema,
    cRoomJoinSchema,
    cHeartbeatSchema,
    cSetFacingSchema,
    // ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema
  ]);
  ```

  **Type-export pattern** (lines 129-135):
  ```typescript
  export type CSetFacing = z.infer<typeof cSetFacingSchema>;
  // ADD: CPositionUpdate, CSetSpriteOverride
  ```

  **Tag every export** with `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` per the precedent at line 83.

  ---

  ### `packages/protocol/src/state.ts` (schema, server→client)

  **Analog:** same file, `facing` field @ line 52 (06.4 D-58c additive precedent)

  **Additive field pattern** (line 52):
  ```typescript
  @type('string') facing: string = 'D';
  ```

  **Wire-version contract note** (lines 33-51 comment — copy this exact reasoning style for the new fields):
  > ADDITIVE FIELD — wire-compatible with Colyseus 0.17 schema-add (RESEARCH §Pitfall 2 + Assumption A2). Does NOT bump PROTOCOL_VERSION...

  For 06.7 the NEW fields DO trigger a PROTOCOL_VERSION bump (3 → 4) because the wire SHAPE (c2s.position_update) is genuinely new, not just an additive optional. The state.ts additions still follow the @colyseus/schema pattern:

  ```typescript
  // 06.7 NEW — add after `facing`:
  @type('number') anim_state: number = 0x06;  // default STAND_D (octant=6, running=0)
  @type('string') sprite_override: string = 'none';
  ```

  ---

  ### `packages/protocol/src/version.ts` (config)

  **Analog:** same file history block (lines 14-22)

  **Bump pattern** (line 24):
  ```typescript
  export const PROTOCOL_VERSION = 3 as const;
  // CHANGE to:
  export const PROTOCOL_VERSION = 4 as const;
  ```

  **History-block append pattern** (lines 14-22 — extend with v4 entry):
  ```typescript
  // History
  //  - 1: Phase 4 baseline (per-tick c2s.input shape + flat PlayerState)
  //  - 2: Phase 6 D-08/D-09 — adds PlayerState.axis_x_held/axis_y_held...
  //  - 3: Phase 6.2 D-51 — adds S2C force_reset variant...
  //  - 4: Phase 06.7 D-05/D-08/D-13 — flips player movement to CLIENT-AUTHORITATIVE
  //       absolute-position streaming. NEW c2s.position_update intent (30 Hz, int16
  //       quantized x/y/vx/vy + facing + anim_state + seq + monotonic_at_ms). NEW
  //       c2s.set_sprite_override event-only intent. PlayerState gains anim_state
  //       (u8 packed) + sprite_override (string). v3 clients close 4400 / MISMATCH.
  ```

  ---

  ### `apps/server/src/onMessageHandlers.ts` (server handler, request-response)

  **Analog:** same file, `set_facing` handler lines 264-301

  **Handler pattern with auth + rate-limit + zod + state-write** (lines 264-301):
  ```typescript
  ctx.room.onMessage('set_facing', (client: Client, raw: unknown) => {
    const auth = ctx.getAuth(client.sessionId);
    if (!auth) {
      log.warn({ sessionId: client.sessionId }, 'no_auth_for_session');
      return;
    }
    if (!rateLimitOrDrop(ctx, client, 'set_facing', auth.account_id)) return;
    const parsed = cSetFacingSchema.safeParse(raw);
    if (!parsed.success) {
      log.warn(
        { msg_type: 'set_facing', sessionId: client.sessionId, issue: parsed.error.message },
        'invalid_intent',
      );
      return;
    }
    const room = ctx.room as unknown as {
      state?: { players?: { get?: (sid: string) => { facing?: string } | undefined } };
    };
    const player = room.state?.players?.get?.(client.sessionId);
    if (!player) {
      log.warn(
        { sessionId: client.sessionId, account_id: auth.account_id },
        'set_facing_no_player_state',
      );
      return;
    }
    player.facing = parsed.data.facing;
  });
  ```

  **Rate-limit msg_type union extension** (line 72):
  ```typescript
  msg_type: 'input' | 'chat_send' | 'heartbeat' | 'room_join' | 'set_facing',
  // EXTEND TO:
  msg_type: 'input' | 'chat_send' | 'heartbeat' | 'room_join' | 'set_facing' | 'position_update' | 'set_sprite_override',
  ```

  **Stale-seq guard** (NEW for position_update only — derived from RESEARCH §Pattern 4):
  ```typescript
  // AFTER zod parse, BEFORE writing PlayerState:
  if (parsed.data.seq <= player.last_input_seq) return;
  player.last_input_seq = parsed.data.seq;
  player.x = parsed.data.x;
  player.y = parsed.data.y;
  // ... etc
  ```

  **Imports update** (lines 25-33):
  ```typescript
  import {
    cChatSendSchema,
    cInputSchema,
    cHeartbeatSchema,
    cRoomJoinSchema,
    cSetFacingSchema,
    encodeS2C,
    type S2C,
  } from '@rebno/protocol';
  // ADD: cPositionUpdateSchema, cSetSpriteOverrideSchema
  ```

  ---

  ### `apps/server/src/RebnoRoom.ts` (room tick, event-driven)

  **Analog:** `tickLoop` in same file lines 1274-1315

  **Current player-input application pattern (TO REMOVE for self-player)** (lines 1289-1313):
  ```typescript
  for (let i = 0; i < ticks; i++) {
    const inputs = new Map<string, InputFrame>();
    for (const [account_id, frame] of this.inputBuffer) {
      inputs.set(account_id, frame);
    }
    this.inputBuffer.clear();
    for (const [account_id, held] of this.heldInputs) {
      inputs.set(account_id, { /* axis_x, axis_y, ... */ });
    }
    const next = step(this.toWorldState(), inputs, TICK_MS);
    this.applyToColyseusState(next);
  }
  ```

  **Target pattern after 06.7** (RESEARCH §Code Examples line 678-696):
  ```typescript
  for (let i = 0; i < ticks; i++) {
    // Server step() ONLY for platforms (REQ-SRV-14). Player position is
    // written by the position_update handler — pass empty inputs map.
    const next = step(this.toWorldState(), new Map(), TICK_MS);
    this.applyToColyseusState(next);
  }
  ```

  **Cleanup note:** the `heldInputs` map + `inputBuffer` writes are vestigial in 06.7 but KEPT per RESEARCH §Open Questions item 3 (06.9 cleanup deferred). The `c2s.input` handler still writes them; the tick loop just stops reading them for player position.

  ---

  ### `apps/client/src/prediction/position-dispatcher.ts` (client dispatcher, NEW)

  **Analog:** `InputDispatcher.sendInput` @ `apps/client/src/prediction/input-dispatcher.ts` lines 256-286

  **Class shape pattern** (lines 76-118 of input-dispatcher.ts — mutable `_room` + setter for reconnect):
  ```typescript
  export class InputDispatcher {
    private _room: Pick<Room, 'send'>;
    get room(): Pick<Room, 'send'> { return this._room; }
    setRoom(room: Pick<Room, 'send'>): void { this._room = room; }
    // ...
  }
  ```

  **Send-payload pattern** (lines 256-265):
  ```typescript
  private sendInput(axes: { x: Axis; y: Axis }): void {
    const intent = this.prediction.enqueueInput(axes);
    this._room.send('input', {
      type: 'input',
      seq: intent.seq,
      axes,
      buttons_down: 0,
      buttons_up: 0,
      monotonic_at_ms: intent.monotonic_at_ms,
    });
  }
  ```

  **Target shape (NEW) — RESEARCH §Code Examples 638-672:**
  ```typescript
  import type { Room } from '@colyseus/sdk';
  import type { PredictionEngine } from './predictor.js';
  import { packAnimState } from '@rebno/protocol';

  export class PositionDispatcher {
    private nextSeq = 0;
    constructor(
      private readonly room: Pick<Room, 'send'>,
      private readonly prediction: PredictionEngine,
      private readonly getFacing: () => Direction,
      private readonly getIsRunning: () => boolean,
    ) {}

    sendTick(monotonic_at_ms: number): void {
      const sim = this.prediction.getLocalState();
      const facing = this.getFacing();
      const isRunning = this.getIsRunning();
      this.room.send('position_update', {
        type: 'position_update',
        x: Math.round(sim.x),
        y: Math.round(sim.y),
        vx: Math.round(sim.vx),
        vy: Math.round(sim.vy),
        facing,
        anim_state: packAnimState(facing, isRunning),
        seq: ++this.nextSeq,
        monotonic_at_ms,
      });
    }
  }
  ```

  **Tag** with `// [impl->REQ-CLI-04] [impl->REQ-CLI-08]` per precedent at input-dispatcher.ts:1.

  ---

  ### `apps/client/src/prediction/reconciler.ts` (reconciler, MODIFY)

  **Analog:** same file `onServerSnapshot` lines 52-122

  **Self-player no-op pattern** (RESEARCH §Pattern 3 — replace the body with an early return when the snapshot is for self):
  ```typescript
  // Current signature: onServerSnapshot(snap: ServerSnapshot): void
  // CHANGE TO accept an isSelfPlayer flag (or detect via account_id comparison):
  onServerSnapshot(snap: ServerSnapshot, isSelfPlayer: boolean): void {
    if (isSelfPlayer) {
      // [impl->REQ-CLI-04] D-04 acceptance criterion: self-player sprite is
      // NEVER written by the snapshot path. Self-player position comes from
      // PredictionEngine.predictTick + InputDispatcher only.
      return;
    }
    // Remote-player path: currently unreachable from production callers
    // (PlayerRenderer.setRemotePosition handles remotes). Kept for symmetry.
  }
  ```

  **Drop the globalThis ring-buffer write** for the self-player branch (lines 73-92 + 100-119) — diagnostic surface is unused once the self-snapshot path is no-op. Mark `DIVERGENCE_THRESHOLD_PX = 22` with `@deprecated 06.7` per RESEARCH §State of the Art.

  **Header-comment update pattern** (lines 1-16):
  ```typescript
  // [impl->REQ-CLI-04]
  // 06.7 D-04 — ReconcileEngine.onServerSnapshot is a NO-OP for the self-player.
  // Self-player position is owned by the client (predictor.ts + PositionDispatcher);
  // snapshot-driven snap/lerp would re-introduce the diagonal-stop ~10 px drift
  // (operator UAT 2026-05-16). The remote-player branch is preserved for symmetry
  // but is not currently called — remote rendering goes through PlayerRenderer.
  ```

  ---

  ### `apps/client/src/net/colyseus-client.ts` (client dispatcher wiring, MODIFY)

  **Analog:** `bindHandlers` @ same file lines 237-258

  **Per-player onChange wiring** (lines 251-258):
  ```typescript
  players.onAdd((player: PlayerState, sessionId: string) => {
    if (sessionId === room.sessionId) cb.onLocalJoin?.(player, sessionId);
    else cb.onRemoteAdd?.(player, sessionId);
    $(player).onChange(() => {
      if (sessionId === room.sessionId) cb.onLocalSnapshot?.(player);
      else cb.onRemoteSnapshot?.(player, sessionId);
    });
  });
  ```

  **Modification:** Wire a `cb.onLocalSnapshot` callback that NO-OPS for self-player (or remove the callback entirely), and ensure `PositionDispatcher.sendTick(monotonic_at_ms)` is invoked from `GameScene.update` on the local sim-tick boundary. The dispatcher does NOT live in `bindHandlers` itself — instantiated where `InputDispatcher` is today and wired with the same `setRoom(room)` lifecycle for reconnect.

  **Pitfall 8 (RESEARCH §Pitfall 8):** the new `anim_state` / `sprite_override` PlayerState fields will fire `$(player).onChange` per-snapshot and the existing callback already reads "every field" — no new wiring needed beyond reading those values from `player.anim_state` / `player.sprite_override` in the renderer callbacks.

  ---

  ## Shared Patterns

  ### Authentication + identity (server-side)
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 104-110 (resolve auth FIRST before rate-limit / parse)
  **Apply to:** every new handler (`position_update`, `set_sprite_override`)
  ```typescript
  const auth = ctx.getAuth(client.sessionId);
  if (!auth) {
    log.warn({ sessionId: client.sessionId }, 'no_auth_for_session');
    return;
  }
  ```

  ### Rate-limit gate BEFORE zod parse
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 69-96 (`rateLimitOrDrop`) + line 112 usage
  **Apply to:** every new handler. RESEARCH §Pitfall 3 budgets: `position_update` → 35 tokens/sec / burst 60; `set_sprite_override` → existing default (rare event).
  ```typescript
  if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;
  ```

  ### `.strict()` zod validation + safeParse + warn
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 113-124
  **Apply to:** every new handler
  ```typescript
  const parsed = cPositionUpdateSchema.safeParse(raw);
  if (!parsed.success) {
    log.warn(
      { msg_type: 'position_update', sessionId: client.sessionId, issue: parsed.error.message },
      'invalid_intent',
    );
    return;
  }
  ```

  ### Server-tagged identity (never trust wire account_id)
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 183-188 (`auth.account_id`, NOT raw)
  **Apply to:** every new handler that writes to PlayerState. PlayerState key is `client.sessionId` (line 284), not a wire-supplied id.

  ### Tag-comment-then-code REQ traceability
  **Source:** `apps/server/src/onMessageHandlers.ts` lines 246-247
  **Apply to:** every new handler/file
  ```typescript
  // [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]
  ctx.room.onMessage('position_update', (client: Client, raw: unknown) => { /* ... */ });
  ```

  ### Test file naming + tag convention
  **Source:** all test files under `apps/server/test/*.test.ts` (unit) + `*.integ.test.ts` (integration)
  **Apply to:** every new test
  - Header comment with `[unit->REQ-*]` or `[int->REQ-*]` tags (e.g., `apps/server/test/d58c-set-facing.integ.test.ts:1-3`)
  - `import { describe, it, expect, afterEach } from 'vitest';` (line 18 of d58c-set-facing.integ.test.ts)
  - `spawnServer()` helper from `./test-utils.js` for integ
  - `joinAs()` helper pattern (d58c-set-facing.integ.test.ts:38-59)
  - `mockAdapter()` / `makeMockRoom()` helpers for unit (reconciler.test.ts:32-50 + input-dispatcher.test.ts:13-29)
  - `vi.fn()` mocks + `mock.calls[N]` assertion style

  ## Per-test-file Pattern Assignments

  ### `apps/server/test/position-update.test.ts` (unit)
  **Analog:** `packages/protocol/test/intents.test.ts` lines 15-90 (zod accept/reject cases)
  **Apply:**
  - Mirror the 5 case-pattern: accepts new shape, accepts axis-edge, REJECTS legacy shape, REJECTS forged identity fields, REJECTS out-of-range.
  - Add stale-seq guard test (specific to 06.7).

  ### `apps/server/test/position-update.integ.test.ts` (integ)
  **Analog:** `apps/server/test/event-driven-input.integ.test.ts` lines 1-100
  **Apply:**
  - `joinClient()` helper (lines 35-53)
  - `positionOf(j)` accessor (lines 55-63) — same shape; read `x, y, anim_state, facing` from PlayerState map
  - two-client flow: client A sends position_update, await `positionOf(B's view of A)` matches

  ### `apps/server/test/sprite-override.test.ts` (handler unit)
  **Analog:** `apps/server/test/d58c-set-facing.integ.test.ts` lines 92-end (set_facing roundtrip)
  **Apply:** mirror set_facing assertion: send `set_sprite_override` with `sprite_id: 'hexport_in'`, assert `PlayerState.sprite_override === 'hexport_in'`.

  ### `apps/server/test/protocol-v4-handshake.integ.test.ts`
  **Analog:** `apps/server/test/protocol-v2-handshake.integ.test.ts` lines 1-68
  **Apply:**
  - `expect(PROTOCOL_VERSION).toBe(4);` (sanity)
  - v3 client receives close code 4400 with `PROTOCOL_VERSION_MISMATCH`
  - v4 client (current) succeeds
  - (Carry-forward the `client.joinOrCreate('rebno', { protocol_version: 3, session_token: 'dev-bypass' })` pattern at line 38.)

  ### `apps/client/src/__test__/position-dispatcher.test.ts`
  **Analog:** `apps/client/src/__test__/input-dispatcher.test.ts` lines 1-80
  **Apply:**
  - `makeMockRoom()` + `makeMockPrediction()` helpers (lines 13-29)
  - `vi.useFakeTimers()` setup/teardown (lines 36-46)
  - Per-call assertion: `room.send.mock.calls[0]![1]` for payload shape (line 52-58)
  - New cases: `sendTick(t)` emits payload matching cPositionUpdateSchema; seq monotonically increments; packAnimState produces correct byte for facing+running combo.

  ### `apps/client/src/__test__/reconciler.test.ts` (ADD case)
  **Analog:** existing same file lines 52-93 (lerp + hard-snap cases)
  **Apply (D-04 acceptance criterion):**
  ```typescript
  it('D-04 06.7 — onServerSnapshot is a NO-OP for self-player (no setPosition, no tweenTo)', () => {
    const m = mockAdapter(100, 100);
    const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
    // Server says (500, 500) — would have triggered hard snap pre-06.7.
    eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true);
    expect(m.setPosition).not.toHaveBeenCalled();
    expect(m.tweenTo).not.toHaveBeenCalled();
  });
  ```

  ### `packages/protocol/test/anim-state.test.ts` (NEW)
  **Analog:** `packages/protocol/test/intents.test.ts` lines 1-15 (header + import style)
  **Apply:**
  - Loop the 16 base poses; round-trip `packAnimState(facing, running)` → `unpackAnimState(byte)` returns same `{facing, running}`.
  - Bit-layout assertions: `packAnimState('D', false) === 0x06`, `packAnimState('DR', true) === 0x0F`, etc. (Reference: RESEARCH §anim_state byte layout table.)

  ### `packages/protocol/test/state.test.ts` (MODIFY)
  **Analog:** existing same file lines 10-30
  **Apply:**
  - Update version literal test: `expect(PROTOCOL_VERSION).toBe(4);`
  - Add defaults for new fields:
    ```typescript
    it('PlayerState defaults — anim_state starts at 0x06 (STAND_D), sprite_override = "none"', () => {
      const p = new PlayerState();
      expect(p.anim_state).toBe(0x06);
      expect(p.sprite_override).toBe('none');
    });
    ```

  ---

  ## CLAUDE.md Hard Rule 1 carve-out (doctrine — D-13)

  **Location:** `CLAUDE.md` §"Hard Rules" Rule 1

  **Current text (locate by Grep):**
  > 1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.

  **Replacement (D-13 — drop scores/combat; carve out movement):**
  > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8.

  Tag the paragraph: `[doc->REQ-SRV-04]` (or whichever REQ traces to Rule 1; verify in `traceable-reqs.toml`).

  ---

  ## `.planning/phases/06.7-.../06.7-HUMAN-UAT.md` (operator UAT script)

  **Analog:** `.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md`

  **Apply:** copy the structure (preconditions, step-by-step operator actions, expected vs observed columns, sign-off section). Cover RESEARCH §Validation row "REQ-CLI-08 manual-only":
  - Diagonal-stop drift bug closed (no ~10 px shift on stop)
  - Dropped-packet hitching closed (simulated 5% loss via Chrome DevTools network conditions — verify smooth visual)
  - Two-player smoke (A moves, B sees A move smoothly; no rubber-band)

  ---

  ## No Analog Found

  None. Every file has a clear precedent inside the repository — this phase is a pure refactor of established patterns. The `position_update` flow is structurally identical to `set_facing` (D-58c) scaled to 30 Hz with int16 fields; the `set_sprite_override` flow is `set_facing` with a different enum; tests mirror existing 06.4 / 06.6 patterns.

  ## Metadata

  **Analog search scope:**
  - `packages/protocol/src/` + `packages/protocol/test/`
  - `apps/server/src/` + `apps/server/test/`
  - `apps/client/src/prediction/` + `apps/client/src/net/` + `apps/client/src/__test__/`
  - `.planning/phases/06.*/` for HUMAN-UAT precedent

  **Files scanned (direct Read):** 11
  - intents.ts, state.ts, version.ts, onMessageHandlers.ts (full + targeted), RebnoRoom.ts (tickLoop region), reconciler.ts (full), input-dispatcher.ts (header + sendInput region), colyseus-client.ts (bindHandlers region), reconciler.test.ts (head), input-dispatcher.test.ts (head), intents.test.ts (head), state.test.ts (head), d58c-set-facing.integ.test.ts (head), protocol-v2-handshake.integ.test.ts (full), event-driven-input.integ.test.ts (head)

  **Pattern extraction date:** 2026-05-17

  ### Plans to Review

  ---
  #### 06.7-01-PLAN.md
  ---
  phase: 06.7
  plan: 01
  type: execute
  wave: 1
  depends_on: []
  files_modified:
    - packages/protocol/src/intents.ts
    - packages/protocol/src/state.ts
    - packages/protocol/src/version.ts
    - packages/protocol/src/anim-state.ts
    - packages/protocol/src/index.ts
    - packages/protocol/test/anim-state.test.ts
    - packages/protocol/test/intents.test.ts
    - packages/protocol/test/state.test.ts
  autonomous: true
  requirements:
    - REQ-SRV-03
    - REQ-CLI-04
    - REQ-CLI-08
  must_haves:
    truths:
      - "Protocol exposes cPositionUpdateSchema accepting {type:'position_update', x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms} with int16 bounds + .strict()"
      - "Protocol exposes cSetSpriteOverrideSchema accepting {type:'set_sprite_override', sprite_id} as a closed enum"
      - "packAnimState(facing, isRunning) and unpackAnimState(byte) round-trip cleanly for all 16 base poses; STAND_D = 0x06, RUN_DR = 0x0F"
      - "PlayerState gains anim_state:number (default 0x06) and sprite_override:string (default 'none')"
      - "PROTOCOL_VERSION is the literal 4; history block records the 06.7 D-05/D-08/D-13 reason"
      - "c2sSchema discriminatedUnion includes cPositionUpdateSchema and cSetSpriteOverrideSchema"
    artifacts:
      - path: "packages/protocol/src/intents.ts"
        provides: "cPositionUpdateSchema + cSetSpriteOverrideSchema + CPositionUpdate/CSetSpriteOverride types"
        contains: "cPositionUpdateSchema"
      - path: "packages/protocol/src/anim-state.ts"
        provides: "FACING_OCTANT record, packAnimState(facing, isRunning), unpackAnimState(byte)"
        contains: "packAnimState"
      - path: "packages/protocol/src/state.ts"
        provides: "PlayerState with anim_state:number and sprite_override:string"
        contains: "anim_state"
      - path: "packages/protocol/src/version.ts"
        provides: "PROTOCOL_VERSION = 4"
        contains: "PROTOCOL_VERSION = 4"
      - path: "packages/protocol/test/anim-state.test.ts"
        provides: "Pack/unpack round-trip + bit-layout assertions"
        contains: "round-trip"
    key_links:
      - from: "packages/protocol/src/index.ts"
        to: "packages/protocol/src/anim-state.ts"
        via: "barrel export"
        pattern: "anim-state"
      - from: "packages/protocol/src/intents.ts"
        to: "c2sSchema discriminatedUnion"
        via: "cPositionUpdateSchema entry"
        pattern: "cPositionUpdateSchema"
      - from: "packages/protocol/src/intents.ts"
        to: "FACING_OCTANT"
        via: "import from anim-state.ts"
        pattern: "from './anim-state"
  ---

  <objective>
  Land the protocol surface that all downstream 06.7 work consumes: new c2s schemas (`cPositionUpdateSchema`, `cSetSpriteOverrideSchema`), the `packAnimState`/`unpackAnimState`/`FACING_OCTANT` helpers, additive `PlayerState` fields (`anim_state`, `sprite_override`), and the PROTOCOL_VERSION 3 → 4 bump. Plans 02 (server) and 03 (client) compile against this surface.

  Purpose: D-05 / D-08 / D-09 / D-10 / D-13 wire-shape decisions are realised here. Interface-first task ordering — define contracts before consumers exist.
  Output: New file `packages/protocol/src/anim-state.ts`; modified `intents.ts`, `state.ts`, `version.ts`, `index.ts`; tests covering round-trip + schema accept/reject + state defaults + version literal.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @packages/protocol/src/intents.ts
  @packages/protocol/src/state.ts
  @packages/protocol/src/version.ts
  @packages/protocol/src/index.ts
  @packages/protocol/test/intents.test.ts
  @packages/protocol/test/state.test.ts

  <interfaces>
  <!-- Authoritative contracts for downstream Plans 02 + 03. Executor should treat -->
  <!-- these as the spec; no codebase exploration required to know shape. -->

  cPositionUpdateSchema (NEW — packages/protocol/src/intents.ts) — per D-05/D-07/D-08/D-10 + RESEARCH §"Recommended position_update Wire Schema":
  - type: literal 'position_update'
  - x:  z.number().int().min(-32768).max(32767)
  - y:  z.number().int().min(-32768).max(32767)
  - vx: z.number().int().min(-32768).max(32767)
  - vy: z.number().int().min(-32768).max(32767)
  - facing: z.enum(['D','DR','R','UR','U','UL','L','DL'])
  - anim_state: z.number().int().min(0).max(255)
  - seq: z.number().int().min(0)
  - monotonic_at_ms: z.number().nonnegative()
  - .strict()

  cSetSpriteOverrideSchema (NEW) — per D-09 + RESEARCH §"set_sprite_override schema":
  - type: literal 'set_sprite_override'
  - sprite_id: z.enum(['hexport_in','hexport_out','tele_in','tele_out','ncol_1','ncol_2','ncol_3','ncol_4','jokershell','watching','none'])
  - .strict()

  FACING_OCTANT (NEW — packages/protocol/src/anim-state.ts):
  - R:0, UR:1, U:2, UL:3, L:4, DL:5, D:6, DR:7

  packAnimState(facing: Direction, isRunning: boolean): number
  - byte = FACING_OCTANT[facing] | (isRunning ? 0x08 : 0)
  unpackAnimState(byte: number): { facing: Direction; running: boolean }
  - octant = byte & 0x07; running = (byte & 0x08) !== 0; facing = inverse table

  PlayerState additive fields (state.ts) — per D-08 + RESEARCH §"Pitfall 5":
    @type('number') anim_state: number = 0x06;       // STAND_D default
    @type('string') sprite_override: string = 'none';

  PROTOCOL_VERSION literal change: `export const PROTOCOL_VERSION = 4 as const;`
  History block entry text — RESEARCH §"Bump pattern".
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: anim-state module + protocol barrel export + round-trip test</name>
    <read_first>
      - packages/protocol/src/index.ts (current barrel exports — confirm pattern for adding a new module)
      - packages/protocol/src/intents.ts (Direction type / facing enum origin — confirm where 'D','DR','R','UR','U','UL','L','DL' is canonically declared so anim-state.ts can import or re-declare consistently)
      - packages/protocol/test/intents.test.ts (test file header conventions, vitest imports, test-tagging style)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"anim_state byte layout (packed 1-byte enum)" and §"facing enum (octant index)" (source-of-truth byte values: STAND_D=0x06, RUN_DR=0x0F, all 16 base poses)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/test/anim-state.test.ts (NEW)"
    </read_first>
    <behavior>
      - packAnimState('D', false) === 0x06
      - packAnimState('DR', false) === 0x07
      - packAnimState('R', false) === 0x00
      - packAnimState('UR', false) === 0x01
      - packAnimState('U', false) === 0x02
      - packAnimState('UL', false) === 0x03
      - packAnimState('L', false) === 0x04
      - packAnimState('DL', false) === 0x05
      - packAnimState('R', true) === 0x08
      - packAnimState('DR', true) === 0x0F
      - packAnimState('D', true) === 0x0E
      - Round-trip: for all 8 facings × {true,false}, unpackAnimState(packAnimState(f, r)) deep-equals { facing: f, running: r }
      - unpackAnimState(0x06) deep-equals { facing: 'D', running: false }
      - unpackAnimState(0x0F) deep-equals { facing: 'DR', running: true }
      - FACING_OCTANT has all 8 keys mapped to the documented 0..7 values
    </behavior>
    <action>
      Create `packages/protocol/src/anim-state.ts` exporting (a) the `FACING_OCTANT` constant record with the 8 keys mapped to 0..7 per the table in RESEARCH §"facing enum (octant index)", (b) an `OCTANT_FACING` inverse lookup (string[8]) for unpack, (c) `packAnimState(facing, isRunning)` returning `FACING_OCTANT[facing] | (isRunning ? 0x08 : 0)`, (d) `unpackAnimState(byte)` returning `{ facing: OCTANT_FACING[byte & 0x07], running: (byte & 0x08) !== 0 }`. The `Direction` type alias is imported from `./intents.ts` if it already exists there; if not, declare a local `Direction = 'D' | 'DR' | 'R' | 'UR' | 'U' | 'UL' | 'L' | 'DL'` and re-export it from anim-state.ts so other modules (PositionDispatcher in Plan 03) can import a single canonical name. Tag the module header `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]`. Add a comment block referencing RESEARCH §"anim_state byte layout" with the full byte-table for future reference.

      Update `packages/protocol/src/index.ts` to re-export the new module: add `export * from './anim-state.js';` near the existing barrel exports.

      Create `packages/protocol/test/anim-state.test.ts`. Follow the file-header tagging style from `packages/protocol/test/intents.test.ts:1-3` with `// [unit->REQ-CLI-04] [unit->REQ-SRV-03]`. Import vitest `describe / it / expect`. Cover the 13 behaviors enumerated above (8 stand bytes + 2-3 named run bytes + round-trip loop + FACING_OCTANT keys). Use a for-loop to iterate all 8 facings × {true,false} for the round-trip; assert per-iteration with `expect(unpackAnimState(packAnimState(f, r))).toEqual({ facing: f, running: r })`.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/anim-state.test.ts</automated>
    </verify>
    <done>anim-state.ts compiles, exports the three named symbols + Direction + OCTANT_FACING; round-trip test green; barrel exports the module so `import { packAnimState } from '@rebno/protocol'` resolves.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "export.*packAnimState" packages/protocol/src/anim-state.ts` matches at least one line
      - Source assertion: `grep -n "FACING_OCTANT" packages/protocol/src/anim-state.ts` matches at least one line
      - Source assertion: `grep -n "from './anim-state" packages/protocol/src/index.ts` matches (barrel updated)
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/anim-state.test.ts` exits 0 and reports the round-trip loop (16 cases) plus the named-byte assertions all passing
      - Tag assertion: `grep -n "impl->REQ-CLI-04" packages/protocol/src/anim-state.ts` matches at least one line
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: cPositionUpdateSchema + cSetSpriteOverrideSchema + discriminated-union registration + intents tests</name>
    <read_first>
      - packages/protocol/src/intents.ts (entire file — cSetFacingSchema @ lines 93-98, cInputSchema @ 45-59, c2sSchema discriminated-union @ 120-127, type-export pattern @ 129-135 are the analogs)
      - packages/protocol/test/intents.test.ts (existing accept/reject test pattern)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/src/intents.ts" (exact analog locations)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Recommended position_update Wire Schema" + §"set_sprite_override schema (rare event)"
    </read_first>
    <behavior>
      - cPositionUpdateSchema.safeParse(valid 18 B payload) returns success=true
      - cPositionUpdateSchema rejects x = 32768 (out of int16 range)
      - cPositionUpdateSchema rejects x = -32769
      - cPositionUpdateSchema rejects facing = 'X' (not in enum)
      - cPositionUpdateSchema rejects anim_state = 256 (out of u8 range)
      - cPositionUpdateSchema rejects anim_state = -1
      - cPositionUpdateSchema rejects seq = -1
      - cPositionUpdateSchema rejects monotonic_at_ms = -1
      - cPositionUpdateSchema rejects extra fields per .strict() (e.g. `account_id: 'attacker'`)
      - cPositionUpdateSchema rejects missing required field (e.g. drop `seq`)
      - cSetSpriteOverrideSchema accepts every documented sprite_id including 'none'
      - cSetSpriteOverrideSchema rejects sprite_id = 'unknown'
      - cSetSpriteOverrideSchema rejects extra fields (.strict)
      - c2sSchema.parse({ type:'position_update', ... }) routes to cPositionUpdateSchema
      - c2sSchema.parse({ type:'set_sprite_override', sprite_id:'hexport_in' }) routes correctly
    </behavior>
    <action>
      In `packages/protocol/src/intents.ts`:
      - Add `cPositionUpdateSchema` after `cSetFacingSchema`. Field-by-field per the `<interfaces>` block above. Use `.strict()`. Tag the export line with `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` per the precedent at intents.ts:83.
      - Add `cSetSpriteOverrideSchema` immediately after `cPositionUpdateSchema`. Use `.strict()`. Same tagging.
      - Extend the existing `c2sSchema = z.discriminatedUnion('type', [...])` array (lines 120-127 today) by appending `cPositionUpdateSchema` and `cSetSpriteOverrideSchema`. KEEP `cInputSchema` in the union per RESEARCH §"Adding cPositionUpdateSchema to the discriminated-union" (pauseMovement contract + test harness path).
      - Export TS types: `export type CPositionUpdate = z.infer<typeof cPositionUpdateSchema>;` and `export type CSetSpriteOverride = z.infer<typeof cSetSpriteOverrideSchema>;` near the existing `CSetFacing` export at lines 129-135.

      Update `packages/protocol/test/intents.test.ts`:
      - Add a `describe('cPositionUpdateSchema (06.7 D-05)', ...)` block. Each `it` covers one of the 10 position_update behaviors enumerated above. Use the existing accept/reject style (`expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true/false)`).
      - Add a `describe('cSetSpriteOverrideSchema (06.7 D-09)', ...)` block covering the 3 override behaviors.
      - Add 2 `it` cases under an existing or new `describe('c2sSchema discriminated-union')` block: position_update routing + set_sprite_override routing.

      Do NOT yet touch `state.ts` or `version.ts` — those are Task 3.

      Tags on each new `it` body: `// [unit->REQ-SRV-03]` for handler-side intent validation; `// [unit->REQ-CLI-04]` where the field originates client-side.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/intents.test.ts</automated>
    </verify>
    <done>Both new schemas exported and reachable from `@rebno/protocol` consumers; discriminated-union dispatches by `type` correctly; all new tests pass; existing intents tests still green.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "cPositionUpdateSchema" packages/protocol/src/intents.ts` matches export + union-array references
      - Source assertion: `grep -n "cSetSpriteOverrideSchema" packages/protocol/src/intents.ts` matches export + union-array references
      - Source assertion: `grep -n "\.strict()" packages/protocol/src/intents.ts | grep -E "(cPositionUpdateSchema|cSetSpriteOverrideSchema)" -B1` shows .strict() on both new schemas (or, simpler, the new schema definitions in the file end with `.strict()` — visually confirm via file diff)
      - Source assertion: `grep -c "cInputSchema" packages/protocol/src/intents.ts` returns ≥ 2 (still present after the union edit)
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/intents.test.ts` exits 0; the new describes report at least 14 passing assertions total (10 position_update + 3 set_sprite_override + 2 c2s union routes — count is a floor, more is fine)
      - Tag assertion: `grep -n "impl->REQ-CLI-04" packages/protocol/src/intents.ts` matches at least one line in the new region
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: PlayerState anim_state + sprite_override fields + PROTOCOL_VERSION bump 3 → 4 + state.test updates</name>
    <read_first>
      - packages/protocol/src/state.ts (full file — `facing` field @ line 52 is the additive analog; the wire-version contract comment block @ lines 33-51 documents the additive convention)
      - packages/protocol/src/version.ts (full file — history block @ lines 14-22 + literal @ line 24)
      - packages/protocol/test/state.test.ts (existing version-literal + defaults tests @ lines 10-30)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"packages/protocol/src/state.ts" + §"packages/protocol/src/version.ts" (exact text for the history-block append)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pitfall 5" (PlayerState extension discipline)
    </read_first>
    <behavior>
      - `new PlayerState().anim_state === 0x06`
      - `new PlayerState().sprite_override === 'none'`
      - `PROTOCOL_VERSION === 4`
      - Existing PlayerState defaults tests still pass (facing default unchanged)
    </behavior>
    <action>
      In `packages/protocol/src/state.ts`:
      - Add two `@type(...)` decorated fields on `PlayerState`, placed immediately after the existing `facing` field at line 52 to mirror the 06.4 D-58c precedent:
        ```
        @type('number') anim_state: number = 0x06;
        @type('string') sprite_override: string = 'none';
        ```
      - Extend the wire-version contract comment block above the class (lines 33-51 region) with a 06.7 entry noting: ADDITIVE (still wire-compatible with v3 state-diff per Pitfall 5), default 0x06 = STAND_D, default 'none' clears any override, populated by the new c2s.position_update / c2s.set_sprite_override handlers added in Plan 02.
      - Do NOT change `last_input_seq`, `vx`, `vy`, `facing` (already present and reused).

      In `packages/protocol/src/version.ts`:
      - Change `export const PROTOCOL_VERSION = 3 as const;` to `export const PROTOCOL_VERSION = 4 as const;` (existing literal at line 24).
      - Append a new history entry under the v3 line, matching the exact wording in PATTERNS.md §"version.ts" block:
        ```
        //  - 4: Phase 06.7 D-05/D-08/D-13 — flips player movement to CLIENT-AUTHORITATIVE
        //       absolute-position streaming. NEW c2s.position_update intent (30 Hz, int16
        //       quantized x/y/vx/vy + facing + anim_state + seq + monotonic_at_ms). NEW
        //       c2s.set_sprite_override event-only intent. PlayerState gains anim_state
        //       (u8 packed) + sprite_override (string). v3 clients close 4400 / MISMATCH.
        ```

      Update `packages/protocol/test/state.test.ts`:
      - Update the existing PROTOCOL_VERSION literal test (lines 10-30 region) to assert `expect(PROTOCOL_VERSION).toBe(4);`.
      - Add an `it` case asserting the two new defaults:
        `expect(new PlayerState().anim_state).toBe(0x06); expect(new PlayerState().sprite_override).toBe('none');`
      - Tag the modified file header with `// [unit->REQ-SRV-03] [unit->REQ-CLI-04]` if not already present.

      Note: this task lands the version bump but does NOT yet add the protocol-v4-handshake integration test — that lives in Plan 02 alongside server changes that produce the rejection path.
    </action>
    <verify>
      <automated>cd packages/protocol && pnpm exec vitest run test/state.test.ts && pnpm exec tsc --noEmit</automated>
    </verify>
    <done>PROTOCOL_VERSION literal = 4; PlayerState defaults match D-08 + D-09; existing facing default test still green; pnpm trace:check passes for protocol package.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "PROTOCOL_VERSION = 4 as const" packages/protocol/src/version.ts` matches exactly once
      - Source assertion: `grep -nE "^//  - 4: Phase 06.7" packages/protocol/src/version.ts` matches the new history line
      - Source assertion: `grep -n "anim_state: number = 0x06" packages/protocol/src/state.ts` matches
      - Source assertion: `grep -n "sprite_override: string = 'none'" packages/protocol/src/state.ts` matches
      - Behavior assertion: `pnpm -C packages/protocol exec vitest run test/state.test.ts` exits 0; the PROTOCOL_VERSION test asserts 4; the new defaults `it` passes
      - Build assertion: `pnpm -C packages/protocol exec tsc --noEmit` exits 0
      - Trace assertion: `pnpm trace:check` does not regress on REQ-SRV-03 / REQ-CLI-04 doc stages (does not fail the build)
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server (`Room.send`) | Untrusted JSON crosses into the room handler; zod is the gate |
  | protocol package consumers | Schema exports must be `.strict()` to deny extra fields by default |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-01a | Tampering | `cPositionUpdateSchema` field bounds | mitigate | Per D-11, server-side SEMANTIC validation of position legality is OUT OF SCOPE — relegated to candidate Phase 06.8. Defense-in-depth at the protocol boundary is still enforced: int16 bounds on x/y/vx/vy (caps malicious unbounded ints), u8 bound on anim_state, enum membership on facing, non-negative on seq + monotonic_at_ms. Implemented via zod `z.number().int().min(-32768).max(32767)` etc.; rejection happens at handler `safeParse` before any state write. |
  | T-06.7-02 | Tampering / Spoofing | `cSetSpriteOverrideSchema` sprite_id | mitigate | `z.enum([...])` is a closed set; `.strict()` rejects extras. Any non-listed sprite_id is rejected at handler-side `safeParse` (Plan 02). |
  | T-06.7-04 | Spoofing | wire-side identity fields (e.g. attacker submits `{ account_id: 'victim' }` on position_update) | mitigate | `.strict()` on every new schema rejects EVERY extra field; server consumes identity from `client.auth.account_id`, never wire. Confirmed by intents.test.ts case "REJECTS forged identity fields" (Task 2). |
  | T-06.7-05 | DoS | giant nested payloads | mitigate | int16 bounds + 1-byte enum + bounded enum strings + non-negative integers — every field is finite, so memory cost per parse is constant. No string-without-length, no deep nesting allowed. |

  **Doctrine change note (D-13):** This plan does NOT modify CLAUDE.md — the Hard Rule 1 carve-out edit lives in Plan 04. But the schemas defined here ARE the substrate that the doctrine change describes: the client-trust model is implemented in protocol shape before doctrine is updated. Plan 04 makes the rule explicit.
  </threat_model>

  <verification>
  - `pnpm -C packages/protocol exec vitest run` green across all protocol test files (intents, state, anim-state, events, version)
  - `pnpm -C packages/protocol exec tsc --noEmit` clean
  - `pnpm trace:check` does not regress
  - `grep -c "PROTOCOL_VERSION = 4" packages/protocol/src/version.ts` returns exactly 1
  - `grep -c "cPositionUpdateSchema" packages/protocol/src/intents.ts | grep -v '^#'` returns ≥ 3 (definition + type infer + discriminated-union entry)
  </verification>

  <success_criteria>
  - All three tasks land their files; tests for each green
  - Downstream Plans 02 + 03 can `import { cPositionUpdateSchema, cSetSpriteOverrideSchema, packAnimState, unpackAnimState, FACING_OCTANT, type CPositionUpdate, type CSetSpriteOverride } from '@rebno/protocol'` with no codebase exploration
  - PROTOCOL_VERSION = 4 visible from any consumer (handshake-rejection test in Plan 02 imports it directly)
  - No regression to existing intents/state tests
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-SUMMARY.md` per the template — call out what consumers can import, the byte-layout reference for future protocol readers, and the v3→v4 history entry for archival.
  </output>

  ---
  #### 06.7-02-PLAN.md
  ---
  phase: 06.7
  plan: 02
  type: execute
  wave: 2
  depends_on:
    - 06.7-01
  files_modified:
    - apps/server/src/onMessageHandlers.ts
    - apps/server/src/RebnoRoom.ts
    - packages/game-logic/src/step.ts
    - apps/server/test/position-update.test.ts
    - apps/server/test/position-update.integ.test.ts
    - apps/server/test/sprite-override.test.ts
    - apps/server/test/protocol-v4-handshake.integ.test.ts
    - packages/game-logic/test/step.test.ts
  autonomous: true
  requirements:
    - REQ-SRV-03
    - REQ-SRV-14
    - REQ-CLI-08
  must_haves:
    truths:
      - "Server accepts c2s.position_update from an authed client; payload mutates that client's PlayerState (x, y, vx, vy, facing, anim_state, last_input_seq)"
      - "Server accepts c2s.set_sprite_override from an authed client; payload mutates that client's PlayerState.sprite_override"
      - "Server rate-limits position_update per (account_id, msg_type='position_update') via the existing token bucket (~35 tokens/s, burst 60)"
      - "Server rejects out-of-order position_update by seq <= last_input_seq"
      - "Server tickLoop runs step() with an empty inputs Map — player position is NOT derived from heldInputs"
      - "Server step() still advances platform positions per (REQ-SRV-14)"
      - "PROTOCOL_VERSION 3 client is rejected at handshake (close code 4400 / PROTOCOL_VERSION_MISMATCH); v4 client succeeds"
      - "Two-client integ: client A sends position_update → B observes PlayerState diff for A within one broadcast cycle (~50ms)"
    artifacts:
      - path: "apps/server/src/onMessageHandlers.ts"
        provides: "position_update + set_sprite_override Colyseus onMessage handlers"
        contains: "position_update"
      - path: "apps/server/src/RebnoRoom.ts"
        provides: "tickLoop with empty-inputs Map; step() runs platforms only"
        contains: "new Map()"
      - path: "packages/game-logic/src/step.ts"
        provides: "Players pass-through; platform advance only"
        contains: "REQ-SRV-14"
      - path: "apps/server/test/position-update.test.ts"
        provides: "Unit tests for zod schema rejection + handler PlayerState writes + stale-seq guard"
        contains: "describe"
      - path: "apps/server/test/position-update.integ.test.ts"
        provides: "Two-client roundtrip integration test"
        contains: "joinClient"
      - path: "apps/server/test/sprite-override.test.ts"
        provides: "Handler unit + schema acceptance test"
        contains: "set_sprite_override"
      - path: "apps/server/test/protocol-v4-handshake.integ.test.ts"
        provides: "v3 client rejected; v4 client accepted at handshake"
        contains: "PROTOCOL_VERSION_MISMATCH"
    key_links:
      - from: "apps/server/src/onMessageHandlers.ts"
        to: "PlayerState.{x,y,vx,vy,facing,anim_state,last_input_seq,sprite_override}"
        via: "Direct field writes after zod safeParse + auth + rate-limit"
        pattern: "player\\.(x|y|vx|vy|facing|anim_state|last_input_seq|sprite_override)\\s*="
      - from: "apps/server/src/RebnoRoom.ts tickLoop"
        to: "step(toWorldState(), new Map(), TICK_MS)"
        via: "Empty inputs Map (was heldInputs-derived)"
        pattern: "step\\(.*new Map\\(\\)"
      - from: "packages/game-logic/src/step.ts"
        to: "platforms advance loop only (player pass-through)"
        via: "input-axis decoding block removed for players; platforms unchanged"
        pattern: "newPlatforms"
  ---

  <objective>
  Wire the server side of the client-trust flip: add `position_update` and `set_sprite_override` Colyseus handlers; modify the room tickLoop to stop deriving player position from `heldInputs`; trim `step()`'s player branch to a pass-through while preserving the platforms branch (REQ-SRV-14); ship integration tests covering the two-client smoke + protocol v3 rejection.

  Purpose: D-05 / D-06 / D-08 / D-09 / D-11 land server-side here. After this plan, an authed client streaming valid `position_update` messages becomes the authoritative source of its own x/y/vx/vy/facing/anim_state; the server stores and rebroadcasts via the existing Colyseus state-diff channel.
  Output: 2 modified source files (handlers + tick loop) + 1 modified game-logic file + 4 new test files (unit, two-client integ, override unit, v4 handshake integ).
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-PLAN.md
  @apps/server/src/onMessageHandlers.ts
  @apps/server/src/RebnoRoom.ts
  @packages/game-logic/src/step.ts
  @apps/server/test/d58c-set-facing.integ.test.ts
  @apps/server/test/event-driven-input.integ.test.ts
  @apps/server/test/protocol-v2-handshake.integ.test.ts

  <interfaces>
  <!-- All consumed from Plan 01. No codebase exploration required. -->

  From @rebno/protocol (Plan 01 deliverable):
    cPositionUpdateSchema    — zod object, .strict(), 9 fields
    cSetSpriteOverrideSchema — zod object, .strict(), { type, sprite_id }
    type CPositionUpdate, type CSetSpriteOverride
    PROTOCOL_VERSION = 4 (literal)
    PlayerState (Colyseus schema) gains:
      anim_state: number = 0x06
      sprite_override: string = 'none'
    Existing fields (pre-06.7): x, y, vx, vy, facing, last_input_seq, account_id, axis_x_held, axis_y_held — UNCHANGED

  Existing server scaffolding (DO NOT redefine — extend/reuse):
    onMessageHandlers.ts § lines 69-96 — `rateLimitOrDrop(ctx, client, msg_type, account_id)` token bucket
    onMessageHandlers.ts § lines 107-110 — `ctx.getAuth(client.sessionId)` resolves auth.account_id
    onMessageHandlers.ts § set_facing handler @ lines 264-301 — analog pattern (auth → rate-limit → zod → state write)
    RebnoRoom.ts § tickLoop @ lines 1274-1315 — current heldInputs/inputBuffer loop
    step.ts § player input application — block being trimmed (per RESEARCH §Pattern 2)
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: position_update + set_sprite_override handlers in onMessageHandlers.ts + handler unit tests</name>
    <read_first>
      - apps/server/src/onMessageHandlers.ts (FULL — set_facing handler @ lines 264-301 is the analog; rate-limit gate @ 69-96; auth resolution @ 107-110; msg_type rate-limit union @ line 72; import block @ 25-33)
      - apps/server/test/d58c-set-facing.integ.test.ts (entire file — test scaffolding: spawnServer, joinAs, vitest pattern)
      - packages/protocol/src/intents.ts (Plan 01 added cPositionUpdateSchema + cSetSpriteOverrideSchema — verify exports compile)
      - packages/protocol/src/state.ts (Plan 01 added anim_state + sprite_override PlayerState fields)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/server/src/onMessageHandlers.ts" §"Shared Patterns" (rate-limit, auth, zod .strict(), server-tagged identity, tag-comment)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 1" §"Pitfall 3" (rate-limit budget 35 tokens/sec / burst 60) §"Pattern 4" (stale-seq guard)
    </read_first>
    <behavior>
      Unit (apps/server/test/position-update.test.ts):
      - Handler with valid payload writes player.x, player.y, player.vx, player.vy, player.facing, player.anim_state, player.last_input_seq, leaves player.sprite_override unchanged
      - Handler rejects when no auth resolved (no PlayerState mutation)
      - Handler rejects when rate-limit returns false (rateLimitOrDrop dropped)
      - Handler rejects malformed payload (zod safeParse failure) — no mutation
      - Handler with stale seq (seq <= player.last_input_seq) returns early — no mutation, no last_input_seq advance
      - Handler with monotonic seq advances last_input_seq and writes new x/y
      - Handler with player not in state.players (e.g. mid-leave race) returns without throw

      Unit (apps/server/test/sprite-override.test.ts):
      - Handler with valid sprite_id='hexport_in' writes player.sprite_override = 'hexport_in'
      - Handler with sprite_id='none' clears the override
      - Handler rejects no-auth / invalid sprite_id / rate-limited cases analogously
    </behavior>
    <action>
      In `apps/server/src/onMessageHandlers.ts`:
      - Extend the `msg_type` union literal (line ~72) to include `'position_update'` and `'set_sprite_override'`.
      - Extend the import block (lines ~25-33) to add `cPositionUpdateSchema, cSetSpriteOverrideSchema` from `@rebno/protocol`.
      - Add the `position_update` handler immediately after the existing `set_facing` handler (the analog at lines 264-301). Implementation order per RESEARCH §Pattern 1 + PATTERNS.md "Shared Patterns":
        1. Resolve `auth = ctx.getAuth(client.sessionId)`; if missing, `log.warn` and return.
        2. `if (!rateLimitOrDrop(ctx, client, 'position_update', auth.account_id)) return;`
        3. `const parsed = cPositionUpdateSchema.safeParse(raw);` — on `!parsed.success`, log warn with msg_type/sessionId/issue and return.
        4. Resolve the player via the same pattern as set_facing: `const player = ctx.room.state?.players?.get?.(client.sessionId);` typed via the local interface trick at lines 152-154; if missing, `log.warn(..., 'position_update_no_player_state')` and return.
        5. **Stale-seq guard:** `if (parsed.data.seq <= player.last_input_seq) return;` (do NOT advance last_input_seq).
        6. Write: `player.last_input_seq = parsed.data.seq; player.x = parsed.data.x; player.y = parsed.data.y; player.vx = parsed.data.vx; player.vy = parsed.data.vy; player.facing = parsed.data.facing; player.anim_state = parsed.data.anim_state;`
        7. Do NOT touch `player.sprite_override` — that's the other handler's domain.
      - Tag the handler block: `// [impl->REQ-CLI-04] [impl->REQ-SRV-03] [impl->REQ-CLI-08]` directly above `ctx.room.onMessage('position_update', ...)` per the precedent at lines 246-247.

      Add the `set_sprite_override` handler immediately after `position_update`. Implementation per RESEARCH §"set_sprite_override schema" + PATTERNS.md "sprite_override" pattern:
      - Same auth → rate-limit → zod → player-resolve flow.
      - On success: `player.sprite_override = parsed.data.sprite_id;` (the string enum value — 'none' is a legitimate clear).
      - No stale-seq guard (no seq field; it's an event-only intent per D-09).
      - Tag: `// [impl->REQ-SRV-03] [impl->REQ-CLI-04]`.

      Create `apps/server/test/position-update.test.ts`. Header tags `// [unit->REQ-SRV-03] [unit->REQ-CLI-04]`. Mirror the test scaffolding from `apps/server/test/d58c-set-facing.integ.test.ts` but UNIT-level (no spawnServer needed — call the handler directly with mocked `ctx`, `client`, `raw`, similar to mock-style server unit tests if any exist; otherwise structure as a thin integ that boots a single-client room). Cover the 7 unit behaviors enumerated above. The stale-seq case is critical (RESEARCH §Pattern 4) — assert that BOTH `player.x` is unchanged AND `player.last_input_seq` is unchanged when seq <= existing.

      Create `apps/server/test/sprite-override.test.ts`. Header tags `// [unit->REQ-SRV-03]`. Cover the override behaviors. Mirror the d58c-set-facing.integ.test.ts assertion shape: send the intent, await the next tick boundary or use `vi.waitFor` against `PlayerState.sprite_override`.
    </action>
    <verify>
      <automated>cd apps/server && pnpm exec vitest run test/position-update.test.ts test/sprite-override.test.ts</automated>
    </verify>
    <done>Both handlers compile against PlayerState's new fields; rate-limit msg_type extended; safeParse rejection paths and stale-seq guard verified; unit tests green.</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "onMessage('position_update'" apps/server/src/onMessageHandlers.ts` matches exactly one line
      - Source assertion: `grep -n "onMessage('set_sprite_override'" apps/server/src/onMessageHandlers.ts` matches exactly one line
      - Source assertion: `grep -n "'position_update'" apps/server/src/onMessageHandlers.ts | grep -c "rateLimitOrDrop"` returns ≥ 1 (rate-limit gate is wired)
      - Source assertion: `grep -nE "if \(parsed\.data\.seq <= player\.last_input_seq\)" apps/server/src/onMessageHandlers.ts` matches the stale-seq guard
      - Source assertion: `grep -c "impl->REQ-SRV-03" apps/server/src/onMessageHandlers.ts` returns a value larger than its pre-06.7 count (the new handler tags landed) — record baseline with `git show HEAD:apps/server/src/onMessageHandlers.ts | grep -c "impl->REQ-SRV-03"` and assert strictly greater
      - Source assertion: rate-limit msg_type union (line ~72) contains both `'position_update'` and `'set_sprite_override'` — `grep -n "msg_type" apps/server/src/onMessageHandlers.ts | grep -c "position_update"` ≥ 1
      - Behavior assertion: `pnpm -C apps/server exec vitest run test/position-update.test.ts test/sprite-override.test.ts` exits 0 with ≥ 9 passing tests (7 + 2 floor)
      - Behavior assertion: existing handler tests do not regress — `pnpm -C apps/server exec vitest run test/d58c-set-facing.integ.test.ts` exits 0
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: RebnoRoom tickLoop empty-inputs + step.ts player pass-through + step regression test</name>
    <read_first>
      - apps/server/src/RebnoRoom.ts (tickLoop region @ lines 1274-1315 — the heldInputs / inputBuffer / step() loop is the surgical target)
      - packages/game-logic/src/step.ts (FULL — the player-input-application block is what becomes pass-through; the platform-advance loop is what's preserved)
      - packages/game-logic/test/step.test.ts (existing tests to preserve as a regression baseline + add a platform-advance assertion if missing)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/server/src/RebnoRoom.ts" + §"step.ts pass-through" (target pattern)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 2" §"Server tickLoop after the change" §"Server-side step() retention" §"Open Questions item 3" (heldInputs vestigial — leave for 06.9 cleanup)
    </read_first>
    <behavior>
      - step({players: { p1: {x:100, y:100, vx:0, vy:0, ...}, ... }, platforms: [...], ...}, new Map() /* empty inputs */, TICK_MS) returns a new state where p1.x === 100 and p1.y === 100 (UNCHANGED — player pass-through)
      - step({platforms: [{x:0, y:0, vx:1, vy:0, cycle_phase:0}]}, new Map(), TICK_MS=50) returns platforms[0].x = 0 + 1*50 = 50 (PLATFORMS UNCHANGED — REQ-SRV-14)
      - step({platforms: [{x:0, y:0, vx:1, vy:0, cycle_phase:0}]}, new Map(), TICK_MS=50) advances cycle_phase by 50
      - tickLoop in RebnoRoom invokes step() with an empty Map (not a heldInputs-derived map)
      - heldInputs writes from cInputSchema handler are NOT touched (vestigial per Open Question 3)
    </behavior>
    <action>
      In `apps/server/src/RebnoRoom.ts` tickLoop (lines 1274-1315):
      - DELETE the inner for-loop that copies heldInputs into the `inputs` Map (lines 1289-1300 region per PATTERNS.md "Current player-input application pattern (TO REMOVE for self-player)").
      - REPLACE the `step(this.toWorldState(), inputs, TICK_MS)` call with `step(this.toWorldState(), new Map(), TICK_MS)` and a comment block citing REQ-SRV-14 + D-01:
        `// 06.7 D-01/D-05 — Player position is now written by the position_update`
        `// handler (onMessageHandlers.ts). step() runs ONLY for platforms`
        `// (REQ-SRV-14). Empty inputs Map: with the step.ts pass-through change,`
        `// players are round-tripped unchanged.`
      - Do NOT delete the `inputBuffer` writes / `heldInputs` writes — left vestigial per Open Question 3 / 06.9 cleanup. Add a TODO comment near the heldInputs declaration: `// TODO(06.9): vestigial post-06.7 — see CONTEXT D-01 / Open Question 3.`

      In `packages/game-logic/src/step.ts`:
      - In the player-input-application block (the loop that reads `inputs.get(account_id)` and applies axes to player vx/vy), GATE the per-player input application on `inputs.size > 0`. When `inputs` is empty (the new server case from 06.7), the players Map round-trips with whatever vx/vy the handler last wrote. Equivalent acceptable patterns: (a) early-skip the input-application loop when `inputs.size === 0`; (b) keep the loop but make each iteration a no-op when `inputs.get(account_id)` returns undefined.
      - Tag the changed region: `// [impl->REQ-SRV-14] — platforms still advance; players are`
        `// pass-through when inputs is empty (06.7 client-trust model).`
      - DO NOT remove the input-application code path entirely. Client-side `predictor.ts` STILL calls `step()` with non-empty inputs for local prediction (RESEARCH §"Pattern 2" "Important nuance"). Both call sites must keep working — server passes empty, client passes its keyboard-derived axes.

      Update `packages/game-logic/test/step.test.ts`:
      - Add (if not already present) an `it` case asserting platform advance: build a state with one platform { x:0, y:0, vx:1, vy:0, cycle_phase:0 } and empty players + empty inputs; call `step(state, new Map(), 50)`; assert `result.platforms[0].x === 50` and `result.platforms[0].cycle_phase === 50`.
      - Add an `it` case asserting player pass-through: build a state with one player at (100, 100, vx:0, vy:0) and empty inputs; call `step(state, new Map(), 50)`; assert `result.players[0].x === 100 && result.players[0].y === 100`.
      - Tag the new cases `// [unit->REQ-SRV-14] [unit->REQ-SRV-03]`.

      KEY NOTE: existing step.test.ts cases that pass non-empty inputs must STILL pass (client-side prediction path is unchanged). Run the full file at the end to confirm.
    </action>
    <verify>
      <automated>cd packages/game-logic && pnpm exec vitest run test/step.test.ts && cd ../../apps/server && pnpm exec tsc --noEmit</automated>
    </verify>
    <done>Server tickLoop calls step with empty Map; step.ts gates input application on inputs.size; all existing step tests still pass; new pass-through + platform-advance regression cases pass; server typecheck clean.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "step\(this\.toWorldState\(\), new Map\(\)" apps/server/src/RebnoRoom.ts` matches exactly one line inside tickLoop
      - Source assertion: tickLoop NO LONGER copies `heldInputs` into the per-tick inputs Map — `grep -nE "for \(const \[account_id, held\] of this\.heldInputs\)" apps/server/src/RebnoRoom.ts` returns zero matches (the copy loop is removed)
      - Source assertion: `grep -nE "inputs\.size === 0|inputs\.size > 0" packages/game-logic/src/step.ts` matches at least one line (the gate is wired)
      - Source assertion: `grep -n "REQ-SRV-14" packages/game-logic/src/step.ts` count increases vs. pre-06.7 baseline (new tag landed)
      - Source assertion: `grep -n "TODO(06.9)" apps/server/src/RebnoRoom.ts` matches the heldInputs vestigial note
      - Behavior assertion: `pnpm -C packages/game-logic exec vitest run test/step.test.ts` exits 0 — all existing tests green PLUS the two new regression cases (pass-through + platform-advance) pass
      - Build assertion: `pnpm -C apps/server exec tsc --noEmit` exits 0
      - Regression assertion: `pnpm -C packages/game-logic exec vitest run` (full game-logic suite) exits 0
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: Two-client position_update integ + protocol-v4-handshake integ</name>
    <read_first>
      - apps/server/test/event-driven-input.integ.test.ts (FULL — analog for two-client integ; joinClient helper, positionOf accessor)
      - apps/server/test/protocol-v2-handshake.integ.test.ts (FULL — analog for handshake-rejection integ; close-code 4400 / PROTOCOL_VERSION_MISMATCH assertion; joinOrCreate `protocol_version` option)
      - apps/server/test/d58c-set-facing.integ.test.ts (FULL — two-client setup pattern reference)
      - apps/server/test/test-utils.* (spawnServer / joinAs helpers — used by the analog tests)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"position-update.integ.test.ts" §"protocol-v4-handshake.integ.test.ts"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Validation Architecture" + §"Pitfall 4"
      - packages/protocol/src/version.ts (Plan 01 set PROTOCOL_VERSION=4)
    </read_first>
    <behavior>
      apps/server/test/position-update.integ.test.ts:
      - Client A joins, client B joins; both authed
      - Client A sends position_update {x:500, y:400, vx:5, vy:0, facing:'R', anim_state:0x08 /* RUN_R */, seq:1, monotonic_at_ms:1000}
      - Within the next setPatchRate cycle (~50ms; use vi.waitFor with 500ms budget), client B's view of PlayerState[A].x equals 500 and y equals 400 and anim_state equals 0x08 and facing equals 'R'
      - Client A sends a SECOND position_update with seq=1 (duplicate); server discards (player.last_input_seq stays at 1, no extra state churn observable)
      - Client A sends position_update with seq=2 and new coords; B observes them
      - Client A sends set_sprite_override {sprite_id:'hexport_in'}; B observes PlayerState[A].sprite_override === 'hexport_in'

      apps/server/test/protocol-v4-handshake.integ.test.ts:
      - `expect(PROTOCOL_VERSION).toBe(4);` sanity
      - A client joining with `joinOrCreate('rebno', { protocol_version: 3, session_token: 'dev-bypass' })` receives the close-code 4400 / PROTOCOL_VERSION_MISMATCH error (mirroring protocol-v2-handshake.integ.test.ts)
      - A client joining with `protocol_version: 4` succeeds (no close, joined state observable)
    </behavior>
    <action>
      Create `apps/server/test/position-update.integ.test.ts`:
      - Header tags `// [int->REQ-SRV-03] [int->REQ-CLI-04] [int->REQ-CLI-08]`.
      - Import vitest + spawnServer + joinAs/joinClient helpers from `./test-utils.js` per the analog files.
      - `describe('position_update integration (06.7)', () => { ... })`.
      - `beforeAll` spawns server; `afterAll` shuts down. Per-test `beforeEach` spawns fresh room if helper supports it; otherwise scope to one shared room.
      - Use the `positionOf(roomB, account_id)` accessor shape from event-driven-input.integ.test.ts:55-63 to read remote-player PlayerState from client B's view.
      - Cover the 5 integ behaviors enumerated above. Use `vi.waitFor` with a 500ms budget for the post-broadcast read (Colyseus setPatchRate is 50ms — 10x margin is safe in CI).
      - For the duplicate-seq case: after sending the second seq=1 update, wait one full patch cycle, then assert `roomB.state.players.get(aSessionId).last_input_seq === 1` (not 0, not 2 — discarded but the first one is still tracked).

      Create `apps/server/test/protocol-v4-handshake.integ.test.ts`:
      - Header tags `// [int->REQ-SRV-03]`.
      - Copy the structure of `protocol-v2-handshake.integ.test.ts` lines 1-68 verbatim, substituting:
        - 2 → 3 (the old version being rejected) — wait, re-read: PROTOCOL_VERSION-2 test rejected version 1. The new v4 test should reject version 3 specifically (the immediate predecessor) and accept version 4. Read the v2 analog to confirm the exact pattern (the v2 analog likely rejects v1 and accepts v2).
        - Asserts `expect(PROTOCOL_VERSION).toBe(4);` at the top.
        - Reject path: `protocol_version: 3` → expect close 4400 with `PROTOCOL_VERSION_MISMATCH`.
        - Accept path: `protocol_version: 4` → expect successful join.
      - Use `client.joinOrCreate('rebno', { protocol_version: <N>, session_token: 'dev-bypass' })` exactly as the analog does at line ~38.
    </action>
    <verify>
      <automated>cd apps/server && pnpm exec vitest run test/position-update.integ.test.ts test/protocol-v4-handshake.integ.test.ts</automated>
    </verify>
    <done>Two-client integ proves the position_update + set_sprite_override roundtrip and the stale-seq guard end-to-end; v3 client rejection is enforced; v4 client still accepted.</done>
    <acceptance_criteria>
      - Source assertion: file `apps/server/test/position-update.integ.test.ts` exists and contains at least one `describe(` block and 5 `it(` blocks
      - Source assertion: file `apps/server/test/protocol-v4-handshake.integ.test.ts` exists; `grep -n "PROTOCOL_VERSION_MISMATCH" apps/server/test/protocol-v4-handshake.integ.test.ts` matches at least once
      - Source assertion: `grep -nE "protocol_version: ?3" apps/server/test/protocol-v4-handshake.integ.test.ts` matches the reject case
      - Source assertion: `grep -nE "protocol_version: ?4" apps/server/test/protocol-v4-handshake.integ.test.ts` matches the accept case
      - Behavior assertion: `pnpm -C apps/server exec vitest run test/position-update.integ.test.ts test/protocol-v4-handshake.integ.test.ts` exits 0 with all listed cases passing
      - Regression assertion: `pnpm -C apps/server exec vitest run` (full server suite) exits 0 — no other tests broken by the rate-limit / state-write changes from Task 1 or the tickLoop change from Task 2
      - Tag assertion: `grep -c "int->REQ-CLI-08" apps/server/test/position-update.integ.test.ts` ≥ 1
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → server `onMessage('position_update')` | zod-validate + rate-limit + server-tagged identity gate the only ingress for client-claimed position |
  | client → server `onMessage('set_sprite_override')` | Same gate; closed enum for sprite_id |
  | server → client `PlayerState` state-diff broadcast | Standard Colyseus channel; no new untrusted data crosses |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-01b | Tampering | client-fabricated `{x, y, vx, vy}` carrying impossible values | **accept (semantic) + mitigate (envelope)** | Per D-11, server semantic legality (speed cap, walkable-grid) is OUT OF SCOPE — deferred to candidate Phase 06.8. **In 06.7 the operator pool is trusted and 50 CCU is the cap.** Envelope-only defense: int16 bounds reject -32769+ values at zod parse; rate-limit token bucket (35 tokens/sec / burst 60) caps tick-rate abuse; stale-seq guard rejects replays. Threat is documented and consciously not mitigated semantically. |
  | T-06.7-02 | Tampering | `set_sprite_override` injection (e.g. unknown sprite_id, malformed type) | mitigate | `cSetSpriteOverrideSchema` is a tight z.enum closed set + `.strict()`. Rejection at handler `safeParse`. No state mutation possible from a malformed payload. |
  | T-06.7-03 | DoS | `position_update` flood from a runaway / hostile client (1000+ Hz) | mitigate | Token bucket per `(account_id, 'position_update')` with refill 35 tokens/sec, burst 60. RESEARCH §"Pitfall 3". `position_update` is added to the existing rate-limit msg_type union; cheap-path-before-zod pattern from onMessageHandlers:69-96 reused. |
  | T-06.7-04 | Spoofing | client sends position_update claiming `{ account_id: 'victim' }` field | mitigate | `.strict()` on cPositionUpdateSchema rejects any extra field. Server-tagged identity from `ctx.getAuth(client.sessionId).account_id` per existing pattern; wire-side identity is never consulted. PlayerState key is `client.sessionId`, never wire-supplied. |
  | T-06.7-06 | Tampering | TCP-reorder replay of an older position_update bumps stale state | mitigate | `if (parsed.data.seq <= player.last_input_seq) return;` stale-seq guard. RESEARCH §"Pattern 4". Cheap; defends against the rare TCP-reorder-after-reconnect window. |
  | T-06.7-07 | Integrity (regression) | A future refactor accidentally re-introduces server-side position derivation, double-writing player.x | mitigate | Player pass-through in step.ts is documented + tagged `// [impl->REQ-SRV-14]`. The platform-advance regression test in Task 2 + the pass-through regression test guard against revert. Plan 03's reconciler-no-op test guards the client side. |

  **Doctrine acknowledgement (D-11):** Server-side anti-cheat envelope is OUT OF SCOPE per CONTEXT D-11. This plan ships ZERO semantic position-legality checks. The trade is acceptable on a pre-launch private staging with ≤50 trusted operators; once cheating becomes observable on real-user traffic OR before public launch, candidate Phase 06.8 ships speed-cap + walkable-grid sanity + fall-reset. This plan's threat register documents that acceptance explicitly so a future verifier reading the post-mortem understands the deferral chain.

  **CLAUDE.md Hard Rule 1 cross-reference:** Plan 04 lands the doctrine edit (D-13) that codifies the trust split. This plan implements the substrate.
  </threat_model>

  <verification>
  - `pnpm -C apps/server test` green for the full server unit suite (position-update.test.ts + sprite-override.test.ts + existing tests)
  - `pnpm -C apps/server test:integration` green for the full server integ suite (position-update.integ.test.ts + protocol-v4-handshake.integ.test.ts + existing integs)
  - `pnpm -C packages/game-logic test` green (step.test.ts platform + pass-through cases + existing cases)
  - `pnpm -C apps/server exec tsc --noEmit` clean
  - `pnpm trace:check` does not regress on REQ-SRV-03 / REQ-SRV-14 / REQ-CLI-08
  - Manual sanity: `grep -nE "onMessage\('position_update'" apps/server/src/onMessageHandlers.ts | wc -l` returns 1 (no accidental duplicate handlers)
  </verification>

  <success_criteria>
  - Server accepts authed position_update + set_sprite_override and writes PlayerState through Colyseus state-diff broadcast — verified by two-client integ
  - Server tickLoop step() is platforms-only — players round-trip; existing platform tests still green
  - v3 client rejected at handshake; v4 client accepted
  - Stale-seq guard verified end-to-end
  - No existing server / game-logic tests broken
  - Plan 03 (client) is unblocked: client can `room.send('position_update', payload)` and observe its own state on the server, plus see remote players' position via the existing PlayerState state-diff channel
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-02-SUMMARY.md`. Note: the heldInputs vestigial cleanup is intentionally deferred to 06.9 — document this with the TODO line-number reference so the future cleanup phase has a concrete starting point. Also document the rate-limit budget choice (35/sec burst 60) so operator dashboards know the dropped-rate baseline.
  </output>

  ---
  #### 06.7-03-PLAN.md
  ---
  phase: 06.7
  plan: 03
  type: execute
  wave: 2
  depends_on:
    - 06.7-01
  files_modified:
    - apps/client/src/prediction/position-dispatcher.ts
    - apps/client/src/prediction/reconciler.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/net/colyseus-client.ts
    - apps/client/src/__test__/position-dispatcher.test.ts
    - apps/client/src/__test__/reconciler.test.ts
  autonomous: true
  requirements:
    - REQ-CLI-04
    - REQ-CLI-08
  must_haves:
    truths:
      - "Client emits a c2s.position_update per sim tick (~30 Hz) populated from PredictionEngine.getLocalState + SpriteStateMachine output"
      - "Client emits a c2s.position_update per sim tick EVEN WHEN THE PLAYER IS IDLE (D-06 steady cadence — getLocalState() returning {vx:0, vy:0} still produces a valid payload that passes cPositionUpdateSchema.safeParse)"
      - "ReconcileEngine.onServerSnapshot is a NO-OP for the self-player (returns early when called from the self-snapshot path) — sprite.setPosition and sprite.tweenTo are never called for self from this code path"
      - "ReconcileEngine.onServerSnapshot for remote players preserves existing lerp/threshold behavior (remote-path symmetry retained per the W7 lock)"
      - "GameScene invokes PositionDispatcher.sendTick on the local sim-tick boundary (the 30 Hz scheduler already in place at apps/client/src/scenes/GameScene.ts:1166-1206 — the `while (this.simTickAccumulator >= SIM_TICK_MS)` loop inside `override update(_time, dt)`)"
      - "GameScene's call site that previously invoked reconcile.onServerSnapshot for self passes the new isSelfPlayer=true flag so the call body returns early (D-04 positive guard)"
      - "globalThis.__rebno.lastReconcile* writes for the self-snapshot path are gone (no diagnostic-ring-buffer entries written when isSelfPlayer=true)"
      - "D-57b and D-58b anti-revert tests are retired (the reconciler-snap behavior they guarded is now intentionally absent); replaced by the positive guard test"
      - "PROTOCOL_VERSION sent on handshake = 4"
    artifacts:
      - path: "apps/client/src/prediction/position-dispatcher.ts"
        provides: "PositionDispatcher class — sendTick(monotonic_at_ms) builds + sends c2s.position_update"
        contains: "PositionDispatcher"
      - path: "apps/client/src/prediction/reconciler.ts"
        provides: "onServerSnapshot with isSelfPlayer self-noop branch + header doc + globalThis ringbuffer removal for self"
        contains: "isSelfPlayer"
      - path: "apps/client/src/scenes/GameScene.ts"
        provides: "PositionDispatcher instantiation + per-tick send; reconciler self-snapshot call passes isSelfPlayer=true"
        contains: "PositionDispatcher"
      - path: "apps/client/src/__test__/position-dispatcher.test.ts"
        provides: "Unit tests — sendTick payload shape, seq monotonicity, packAnimState wiring, D-06 idle cadence guard"
        contains: "sendTick"
      - path: "apps/client/src/__test__/reconciler.test.ts"
        provides: "D-04 positive-guard it case — self-snapshot is no-op + retiring D-57b/D-58b cases + remote-symmetry preserved tests use isSelfPlayer=false"
        contains: "D-04"
    key_links:
      - from: "apps/client/src/scenes/GameScene.ts (per-tick update loop)"
        to: "PositionDispatcher.sendTick(monotonic_at_ms)"
        via: "invoked from the same scheduler that calls predictTick — GameScene.ts:1166-1206 (inside `override update(_time, dt)`), immediately after `this.prediction.predictTick(...)` at line 1168"
        pattern: "positionDispatcher\\.sendTick|posDispatcher\\.sendTick"
      - from: "apps/client/src/prediction/reconciler.ts onServerSnapshot"
        to: "early-return when isSelfPlayer"
        via: "isSelfPlayer flag parameter (or local-id comparison)"
        pattern: "if \\(isSelfPlayer\\)"
      - from: "apps/client/src/scenes/GameScene.ts:761 (existing reconcile call site)"
        to: "onServerSnapshot(snap, isSelfPlayer=true)"
        via: "pass the new flag (or stop calling for self entirely)"
        pattern: "reconcile\\?\\.onServerSnapshot"
  ---

  <objective>
  Wire the client side of the client-trust flip: introduce `PositionDispatcher` that ships the new c2s.position_update every sim tick (including idle ticks per D-06); make `ReconcileEngine.onServerSnapshot` a no-op for the self-player (positive D-04 guard) while PRESERVING the remote-player lerp/threshold path for signature symmetry (W7 lock); remove the self-path diagnostic-ring-buffer writes; update GameScene to instantiate the dispatcher, schedule its `sendTick` inside the existing 30 Hz `override update(_time, dt)` accumulator loop at lines 1166-1206, and pass `isSelfPlayer=true` when invoking the reconciler for self; bump handshake `protocol_version` to 4.

  Purpose: D-04 (D-57b/D-58b retired + positive guard) + D-05/D-06/D-07/D-08/D-12 land client-side here. After this plan, self-player position is OWNED by the client; the round-trip diagonal-stop drift bug from operator UAT 2026-05-16 cannot recur (no snap path remains).
  Output: 1 NEW dispatcher file + reconciler self-noop edits (remote path preserved) + GameScene wiring + colyseus-client handshake bump + 2 test files (1 new dispatcher unit + reconciler test additions/retirements). Plan touches 6 files with 5 edit-regions in GameScene.ts (import + field declaration + instantiation + per-tick invocation + reconnect setRoom + isSelfPlayer pass-through); kept co-located in a single task per W6 acceptance.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-01-PLAN.md
  @apps/client/src/prediction/predictor.ts
  @apps/client/src/prediction/reconciler.ts
  @apps/client/src/prediction/input-dispatcher.ts
  @apps/client/src/net/colyseus-client.ts
  @apps/client/src/scenes/GameScene.ts
  @apps/client/src/__test__/reconciler.test.ts
  @apps/client/src/__test__/input-dispatcher.test.ts

  <interfaces>
  <!-- From Plan 01 (@rebno/protocol) — already importable: -->
    packAnimState(facing, isRunning): number
    FACING_OCTANT: Record<Direction, number>
    type CPositionUpdate, type Direction (or imported from intents.ts via re-export)
    cPositionUpdateSchema (for test fixtures, optional)
    PROTOCOL_VERSION (literal 4)

  <!-- From existing client code (KEEP — extend, do not redefine): -->
    PredictionEngine in apps/client/src/prediction/predictor.ts
      - getLocalState(): { x: number, y: number, vx: number, vy: number }
        (or equivalent — if the exact name differs, executor confirms via Read first; the spec is "current local sim state for self")
      - predictTick(layout, axes) — runs locally each frame (UNCHANGED in 06.7)
    SpriteStateMachine in apps/client/src/render/SpriteStateMachine.ts
      - exposes current pose / running state (consumed by getIsRunning callback)
    InputDispatcher in apps/client/src/prediction/input-dispatcher.ts
      - sendInput(axes) — for chat-focus pauseMovement contract (KEPT vestigially; not the focus of this plan)
      - HEARTBEAT_INTERVAL_MS = 15_000 (KEPT — used in chat-focus / menu state)
    ReconcileEngine in apps/client/src/prediction/reconciler.ts
      - onServerSnapshot(snap, isSelfPlayer) — body becomes self-noop branch; remote-path lerp/threshold preserved per W7 lock
    GameScene in apps/client/src/scenes/GameScene.ts
      - line 79: import { ReconcileEngine }
      - line 131: this.reconcile field declaration
      - line 709: this.reconcile = new ReconcileEngine(...)
      - line 761: this.reconcile?.onServerSnapshot({ ... }) — the self-call site
      - line 1152: `override update(_time: number, dt: number): void` — the per-frame entry
      - lines 1166-1206: `while (this.simTickAccumulator >= SIM_TICK_MS)` — the 30 Hz sim-tick loop; predictTick() runs at 1168; PositionDispatcher.sendTick() is wired immediately after the predictTick call inside this same loop body (one tick = one position_update, including idle ticks per D-06)

  <!-- Wire shape (from Plan 01): -->
    c2s.position_update payload = {
      type: 'position_update',
      x, y, vx, vy: int16-rounded
      facing: 'D'|'DR'|...
      anim_state: u8 packed via packAnimState
      seq: u32 monotonic per dispatcher
      monotonic_at_ms: u32
    }
  </interfaces>
  </context>

  <tasks>

  <task type="auto" tdd="true">
    <name>Task 1: PositionDispatcher class + unit tests (incl. D-06 idle-cadence guard)</name>
    <read_first>
      - apps/client/src/prediction/input-dispatcher.ts (FULL — `sendInput` @ lines 256-286 is the analog; the class shape with `_room`/`setRoom` @ lines 76-118 is the lifecycle precedent for Colyseus reconnect; HEARTBEAT_INTERVAL_MS @ line 26)
      - apps/client/src/prediction/predictor.ts (the PredictionEngine API — confirm the exact name of the "get current local sim state" method; predictTick + getLocalState or equivalent)
      - apps/client/src/render/SpriteStateMachine.ts (the SpriteStateMachine API — confirm how to query current facing + isRunning; the position_update needs both)
      - apps/client/src/__test__/input-dispatcher.test.ts (FULL — `makeMockRoom`, `makeMockPrediction`, `vi.useFakeTimers` pattern; this is the test scaffolding to mirror)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/prediction/position-dispatcher.ts (NEW)"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Position dispatcher (client, every-tick send)" (full code spec)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-VALIDATION.md (row "D-06 idle cadence guard — PositionDispatcher.sendTick emits valid payload when getLocalState returns {vx:0, vy:0}")
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-06 (steady 30 Hz cadence; idle player still emits position_update)
      - packages/protocol/src/anim-state.ts (Plan 01 — packAnimState + FACING_OCTANT exports)
    </read_first>
    <behavior>
      - new PositionDispatcher(room, prediction, getFacing, getIsRunning); calling sendTick(1000) invokes room.send('position_update', payload) exactly once
      - The payload's `type` field equals 'position_update'
      - The payload's x, y, vx, vy are Math.round() of the prediction.getLocalState() values (i.e. quantized to int16)
      - The payload's `facing` equals what `getFacing()` returns
      - The payload's `anim_state` equals `packAnimState(getFacing(), getIsRunning())`
      - The payload's `seq` is monotonically increasing across multiple sendTick calls (1, 2, 3, ...)
      - The payload's `monotonic_at_ms` equals the value passed to sendTick
      - setRoom(newRoom) routes subsequent sendTick calls to newRoom (Colyseus reconnect lifecycle); the old room.send is NOT called after setRoom
      - cPositionUpdateSchema.safeParse(payload).success === true (envelope correctness)
      - **D-06 idle-cadence guard (REQ-CLI-04 — addresses checker W3):** when `prediction.getLocalState()` returns `{ x: 500, y: 400, vx: 0, vy: 0 }` (the player has been idle for N frames), sendTick(monotonic_at_ms) STILL invokes `room.send('position_update', payload)` exactly once and the emitted payload passes `cPositionUpdateSchema.safeParse(...).success === true`. The dispatcher does NOT skip / coalesce / suppress idle ticks. Asserts (a) `room.send` call count == 1 per sendTick invocation regardless of vx/vy = 0, (b) `payload.vx === 0`, `payload.vy === 0`, and (c) the safeParse success — proving the server's zod gate will accept the idle frame.
    </behavior>
    <action>
      Create `apps/client/src/prediction/position-dispatcher.ts`. Tag the module header `// [impl->REQ-CLI-04] [impl->REQ-CLI-08]`. Implement per the spec in RESEARCH §"Position dispatcher (client, every-tick send)" and PATTERNS.md §"Target shape (NEW)":
      - Class `PositionDispatcher` with constructor taking `(room: Pick<Room, 'send'>, prediction: PredictionEngine, getFacing: () => Direction, getIsRunning: () => boolean)`.
      - Mutable `_room` + `setRoom(room)` setter mirroring InputDispatcher lines 76-118 for Colyseus reconnect lifecycle.
      - `private nextSeq = 0;`
      - Public `sendTick(monotonic_at_ms: number): void` that builds the payload from `prediction.getLocalState()` (confirm exact method name during the Read step; if the API exposes the local sim state under a different name, adapt the action — the contract is "current local sim x/y/vx/vy for self"), rounds each numeric field with Math.round, queries facing + running via the injected callbacks, packs anim_state, increments seq, and calls `_room.send('position_update', { type, x, y, vx, vy, facing, anim_state, seq, monotonic_at_ms })`. **Per D-06 the method does NOT gate on vx/vy = 0** — idle player still emits a payload every tick (steady 30 Hz cadence).
      - Imports: `import { packAnimState, type Direction } from '@rebno/protocol';` + type Room from '@colyseus/sdk' + the predictor type.

      Create `apps/client/src/__test__/position-dispatcher.test.ts`. Tag header `// [unit->REQ-CLI-04] [unit->REQ-CLI-08]`. Mirror `apps/client/src/__test__/input-dispatcher.test.ts` lines 1-80:
      - `makeMockRoom()` returns `{ send: vi.fn() }`.
      - `makeMockPrediction()` returns an object exposing whatever method PositionDispatcher consumes (`getLocalState()` returns `{ x: 100.7, y: 200.2, vx: 1.1, vy: -2.4 }` or similar — non-integer to verify Math.round). Also export a helper `makeIdlePrediction()` returning `{ getLocalState: () => ({ x: 500, y: 400, vx: 0, vy: 0 }) }` for the D-06 case below.
      - Use `vi.useFakeTimers()` setup if any timer interaction is needed (probably none — sendTick is synchronous).
      - Test cases mirror the 10 behaviors above (the original 9 + the new D-06 idle-cadence guard). Assertion shape: `room.send.mock.calls[0]![1]` is the payload object; compare field-by-field with `toEqual` / `toMatchObject`.
      - Round-trip case: `expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true)` — proves the dispatcher emits a payload that the server's zod gate will accept.
      - **D-06 idle-cadence test (NEW — addresses W3):**
        ```
        it('D-06 06.7 — sendTick emits a valid payload when player is idle (vx=0, vy=0)', () => {
          const room = makeMockRoom();
          const prediction = makeIdlePrediction(); // getLocalState() → { x: 500, y: 400, vx: 0, vy: 0 }
          const dispatcher = new PositionDispatcher(room, prediction, () => 'D', () => false);
          dispatcher.sendTick(1000);
          expect(room.send).toHaveBeenCalledTimes(1);
          const [, payload] = room.send.mock.calls[0]!;
          expect(payload.vx).toBe(0);
          expect(payload.vy).toBe(0);
          expect(payload.x).toBe(500);
          expect(payload.y).toBe(400);
          expect(cPositionUpdateSchema.safeParse(payload).success).toBe(true);
        });
        ```

      Note: this task does NOT wire the dispatcher into GameScene — that's Task 3. The dispatcher must be unit-testable in isolation.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/position-dispatcher.test.ts</automated>
    </verify>
    <done>PositionDispatcher class exists, is exportable, builds correct payloads that pass cPositionUpdateSchema.safeParse; D-06 idle-cadence guard test green; unit tests green.</done>
    <acceptance_criteria>
      - Source assertion: file `apps/client/src/prediction/position-dispatcher.ts` exists; `grep -n "export class PositionDispatcher" apps/client/src/prediction/position-dispatcher.ts` matches exactly one line
      - Source assertion: `grep -n "packAnimState" apps/client/src/prediction/position-dispatcher.ts` matches at least one line (anim_state wiring)
      - Source assertion: `grep -n "Math.round" apps/client/src/prediction/position-dispatcher.ts` matches at least 4 lines (x, y, vx, vy rounding per D-07)
      - Source assertion: `grep -n "setRoom" apps/client/src/prediction/position-dispatcher.ts` matches at least one line (reconnect lifecycle)
      - Source assertion: file `apps/client/src/__test__/position-dispatcher.test.ts` exists with at least 9 `it(` blocks (8 original + the new D-06 idle-cadence case)
      - Source assertion: `grep -nE "D-06 06\.7|D-06 idle|idle cadence" apps/client/src/__test__/position-dispatcher.test.ts` matches at least one line (the D-06 idle test is present)
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/position-dispatcher.test.ts` exits 0; both the cPositionUpdateSchema.safeParse-success case AND the D-06 idle-cadence case pass
      - Tag assertion: `grep -c "impl->REQ-CLI-04" apps/client/src/prediction/position-dispatcher.ts` ≥ 1
      - Tag assertion: `grep -c "unit->REQ-CLI-04" apps/client/src/__test__/position-dispatcher.test.ts` ≥ 1
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 2: ReconcileEngine self-noop + reconciler test (D-04 positive guard, retire D-57b/D-58b, PRESERVE remote symmetry)</name>
    <read_first>
      - apps/client/src/prediction/reconciler.ts (FULL — onServerSnapshot @ 52-122 is the surgical target; the globalThis.__rebno.lastReconcile* writes @ 73-92 + 100-119 are what's being removed for the self-path)
      - apps/client/src/__test__/reconciler.test.ts (FULL — the existing 5+ it cases including the D-57b/D-58b anti-revert ones; identify which ones to retire vs. keep with isSelfPlayer=false)
      - apps/client/src/scenes/GameScene.ts (lines 700-790 — the existing call site at line 761 that invokes reconcile.onServerSnapshot for self; that signature changes to accept isSelfPlayer)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/prediction/reconciler.ts"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pattern 3" §"Pitfall 1" §"Pitfall 6"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-04 (the retire-then-replace acceptance criterion)
    </read_first>
    <behavior>
      Reconciler unit (apps/client/src/__test__/reconciler.test.ts):
      - D-04 positive guard: `eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true)` produces ZERO calls to `m.setPosition` and ZERO calls to `m.tweenTo` (mockAdapter spies)
      - D-04 corollary: even with extreme divergence (>22 px threshold), self-path is no-op
      - D-04 corollary: the globalThis ringbuffer for self is NOT written (`globalThis.__rebno?.lastReconcileSelfX` etc. remain undefined or at their pre-call value)
      - Existing D-57b and D-58b anti-revert tests are REMOVED from the file (their preconditions no longer make sense — they asserted snap-on-divergence behavior that is intentionally absent)
      - **Remote-symmetry preserved (W7 lock):** any existing lerp/threshold cases for the REMOTE-player path are RETAINED and updated to pass `isSelfPlayer=false` EXPLICITLY. The remote-player branch of `onServerSnapshot` is NOT collapsed to a no-op; the existing lerp/threshold sprite-write behavior is preserved end-to-end. Specifically: a remote case calling `onServerSnapshot(snap, /* isSelfPlayer */ false)` with a >22 px divergence MUST still produce a `m.setPosition` (or `m.tweenTo`) call, matching the pre-06.7 remote-reconciliation behavior. A remote case below threshold continues to behave as it did pre-06.7.

      Reconciler source (apps/client/src/prediction/reconciler.ts):
      - onServerSnapshot signature accepts `isSelfPlayer: boolean` as a second parameter (or new option object — pick the cleanest fit for the existing callers, which is GameScene only)
      - When `isSelfPlayer === true`, the function returns early immediately after the param decode; NO sprite writes, NO tween, NO globalThis writes
      - When `isSelfPlayer === false` (remote-player path — **PRESERVED per W7 lock**) the existing lerp/threshold + sprite-write behavior stays intact. Do NOT collapse to a no-op. Do NOT delete the divergence-threshold sprite write. Remote-player rendering is OUT OF SCOPE for 06.7 changes — only the self-player branch flips.
    </behavior>
    <action>
      In `apps/client/src/prediction/reconciler.ts`:
      - Update the file header (lines 1-16) per PATTERNS.md §"Header-comment update pattern":
        ```
        // [impl->REQ-CLI-04]
        // 06.7 D-04 — ReconcileEngine.onServerSnapshot is a NO-OP for the self-player.
        // Self-player position is owned by the client (predictor.ts + PositionDispatcher);
        // snapshot-driven snap/lerp would re-introduce the diagonal-stop ~10 px drift
        // (operator UAT 2026-05-16). The REMOTE-player branch is PRESERVED with its
        // existing lerp/threshold behavior — only the self-player branch flips
        // (W7 lock: remote symmetry intentionally retained; remote rendering
        // continues to flow through onServerSnapshot's remote-path code).
        ```
      - Change the signature of `onServerSnapshot(snap)` to `onServerSnapshot(snap, isSelfPlayer: boolean)`.
      - At the very top of the method body: `if (isSelfPlayer) { return; }` — comment `// [impl->REQ-CLI-04] D-04 positive guard — see file header.`
      - REMOVE the globalThis.__rebno.lastReconcile* writes that occur on the self-path (per PATTERNS.md "Drop the globalThis ring-buffer write for the self-player branch (lines 73-92 + 100-119)"). The diagnostic ring-buffer is no longer useful for self; it remains in the file's history for posterity but the runtime no longer writes when isSelfPlayer=true.
      - **PRESERVE the remote-path lerp/threshold code below the self-guard return.** Do NOT delete `DIVERGENCE_THRESHOLD_PX` constant. Do NOT delete the sprite.setPosition/tweenTo call paths in the remote branch. Remove the `@deprecated` JSDoc note that the previous plan-revision draft suggested — per W7 the constant IS still live for remote reconciliation.

      In `apps/client/src/scenes/GameScene.ts`:
      - Update the existing call site at line ~761 `this.reconcile?.onServerSnapshot({ ... })` to pass `isSelfPlayer: true` as the new second argument. (The call site IS the self-snapshot path — verify by re-reading the surrounding context at lines 740-780; the snapshot is built from the local player's PlayerState row, so isSelfPlayer is unconditionally true here.) Prefer passing `isSelfPlayer=true` to preserve the call structure and avoid touching the surrounding fired-callback logic at line 776.
      - Add `scene.tweens.killTweensOf(localSprite)` defensively in the predictor-write path per RESEARCH §"Pitfall 6" (Phaser tween fighting predictor) — only if a tween reference still exists in the local-sprite write path. If no in-flight tween path remains (i.e. no GameScene code calls `tweenTo` against localSprite outside of the now-noop reconciler), skip this defensive call. Document the decision in a code comment.

      In `apps/client/src/__test__/reconciler.test.ts`:
      - Add the D-04 positive-guard `it` block per PATTERNS.md §"reconciler.test.ts (ADD case)":
        ```
        it('D-04 06.7 — onServerSnapshot is a NO-OP for self-player (no setPosition, no tweenTo)', () => {
          const m = mockAdapter(100, 100);
          const eng = new ReconcileEngine(prediction, m.adapter, () => FLAT_LAYOUT);
          eng.onServerSnapshot({ x: 500, y: 500, vx: 0, vy: 0, last_input_seq: 0 }, /* isSelfPlayer */ true);
          expect(m.setPosition).not.toHaveBeenCalled();
          expect(m.tweenTo).not.toHaveBeenCalled();
        });
        ```
      - Add a second case asserting that the globalThis ring-buffer is NOT written when isSelfPlayer=true (sanity).
      - REMOVE (delete) the D-57b and D-58b anti-revert test cases entirely — they guard the now-absent SELF snap behavior. Per CONTEXT D-04 these are EXPLICITLY RETIRED as an acceptance criterion of this phase. Add a deleted-block tombstone comment near the top of the file: `// D-57b + D-58b anti-revert tests retired 06.7 per CONTEXT D-04 — replaced by the D-04 positive-guard above. Remote-path lerp/threshold tests RETAINED per W7 lock; updated to pass isSelfPlayer=false explicitly.`
      - **REMOTE SYMMETRY (W7 lock):** For ALL remaining lerp/threshold cases — including the below-threshold no-op case AND the above-threshold sprite-write case — update each call to pass `isSelfPlayer: false` explicitly. These tests MUST continue to pass after the signature change. If any existing remote-path test currently calls `eng.onServerSnapshot(snap)` without a flag, change to `eng.onServerSnapshot(snap, /* isSelfPlayer */ false)`. Do NOT retire the remote-path cases — they guard the remote-player rendering that the 06.7 operator UAT does NOT exercise (UAT focuses on self-player movement). Removing them risks an undetected remote-rendering regression.

      Update the mock adapter trick `apps/client/src/__test__/game-scene.test.ts:91-92` (the `ReconcileEngine: class { onServerSnapshot = vi.fn(); }` mock) only if signature change breaks the mock — the vi.fn() accepts any args, so likely no change needed. Re-run the suite to confirm.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/reconciler.test.ts</automated>
    </verify>
    <done>Reconciler self-noop is enforced + tested; D-57b/D-58b retired; D-04 positive guard passes; remote-path lerp/threshold tests preserved with explicit isSelfPlayer=false; GameScene caller updated; no Phaser-tween-fighting-predictor regression possible.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "if \(isSelfPlayer\)" apps/client/src/prediction/reconciler.ts` matches at least one line inside onServerSnapshot
      - Source assertion: `grep -nE "isSelfPlayer: boolean" apps/client/src/prediction/reconciler.ts` matches the signature
      - Source assertion: `grep -n "06.7 D-04" apps/client/src/prediction/reconciler.ts` matches the header comment
      - Source assertion (W7 lock): `grep -n "DIVERGENCE_THRESHOLD_PX" apps/client/src/prediction/reconciler.ts` matches at least one line (constant retained, NOT deprecated)
      - Source assertion (W7 lock): the remote-path sprite-write code is intact — `grep -nE "setPosition|tweenTo" apps/client/src/prediction/reconciler.ts` matches at least one line BELOW the self-guard return
      - Source assertion: D-57b and D-58b cases are GONE from the test file — `grep -cE "describe.*D-57b|describe.*D-58b|it.*D-57b|it.*D-58b" apps/client/src/__test__/reconciler.test.ts` returns 0 OR matches only the retirement tombstone comment (verify by reading the diff)
      - Source assertion: D-04 positive guard exists — `grep -n "D-04 06.7" apps/client/src/__test__/reconciler.test.ts` matches at least one line
      - Source assertion (W7 lock): at least one remote-path test uses the explicit flag — `grep -nE "isSelfPlayer.*false|/\*\s*isSelfPlayer\s*\*/\s*false" apps/client/src/__test__/reconciler.test.ts` matches at least one line
      - Source assertion: the GameScene call site passes the new flag — `grep -nE "onServerSnapshot\(\{[^}]*\},\s*(true|/\*\s*isSelfPlayer\s*\*/\s*true)" apps/client/src/scenes/GameScene.ts` matches
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/reconciler.test.ts` exits 0 with the D-04 case passing AND the preserved remote-path lerp/threshold cases passing
      - Regression assertion: `pnpm -C apps/client exec vitest run src/__test__/game-scene.test.ts` exits 0 (the mock-adapter trick still works with the new signature)
      - Tag assertion: `grep -c "impl->REQ-CLI-04" apps/client/src/prediction/reconciler.ts` ≥ 1
    </acceptance_criteria>
  </task>

  <task type="auto" tdd="true">
    <name>Task 3: GameScene + colyseus-client wiring — instantiate dispatcher, schedule sendTick at GameScene.ts:1166-1206, handshake protocol_version=4</name>
    <read_first>
      - apps/client/src/scenes/GameScene.ts (FULL — line 79 ReconcileEngine import; line 131 reconcile field; line 709 reconcile instantiation; line 761 self-snapshot call; **line 1152 `override update(_time: number, dt: number)` — the per-frame entry**; **lines 1166-1206 `while (this.simTickAccumulator >= SIM_TICK_MS)` — the existing 30 Hz sim-tick scheduler**; **line 1168 `const localSim = this.prediction.predictTick(this.currentLayoutForSim());` — the per-tick predictTick invocation that PositionDispatcher.sendTick is wired next to**; the InputDispatcher per-tick path uses `axisX()`/`axisY()` at 1174-1175 — the dispatcher itself does not have a per-sim-tick send in this file, so PositionDispatcher.sendTick is the new 30 Hz call site)
      - apps/client/src/net/colyseus-client.ts (FULL — `bindHandlers` @ lines 237-258, the player onAdd / onChange wiring; the handshake's `joinOrCreate` call — where the `protocol_version` argument is set; current literal 3 needs to become 4)
      - apps/client/src/prediction/input-dispatcher.ts (the InputDispatcher per-tick or per-event scheduling pattern; PositionDispatcher's scheduling lives in the same scene loop)
      - apps/client/src/__test__/input-dispatcher.test.ts (test patterns for any GameScene-level dispatcher schedule, if applicable)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"apps/client/src/net/colyseus-client.ts" §"Per-player onChange wiring"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Pitfall 4" (PROTOCOL_VERSION bump deploy ritual)
      - packages/protocol/src/version.ts (Plan 01 — PROTOCOL_VERSION = 4)
    </read_first>
    <behavior>
      - GameScene instantiates a PositionDispatcher alongside InputDispatcher and stores it on the scene (private field declared alongside the existing `reconcile` field at line ~131)
      - **Scheduler hook (pre-resolved per W4):** GameScene invokes `this.positionDispatcher?.sendTick(_time)` INSIDE the existing 30 Hz `while (this.simTickAccumulator >= SIM_TICK_MS)` loop in `override update(_time, dt)` at apps/client/src/scenes/GameScene.ts:1166-1206. The call is placed IMMEDIATELY AFTER the existing `const localSim = this.prediction.predictTick(...)` invocation at line 1168 (executor reads the surrounding context lines 1166-1206 to splice the new call at the canonical spot — same loop iteration, after predictTick completes, before or after the remote-player onSimulationTickRemote loop; either ordering is acceptable since sendTick is independent of remote state). DO NOT invent a new timer. DO NOT use `performance.now()` — use the `_time` parameter already passed to `override update(_time, dt)` (it is Phaser's monotonic timestamp and is what the rest of the loop body uses for telemetry at line 1218).
      - On Colyseus reconnect, the scene calls `this.positionDispatcher?.setRoom(newRoom)` (mirroring InputDispatcher.setRoom)
      - colyseus-client.ts handshake sends `protocol_version: 4` (NOT 3) — verify literal change OR confirm the value is sourced from `PROTOCOL_VERSION` from `@rebno/protocol` and that the source value is 4 (Plan 01)
      - The reconciler self-snapshot call site in GameScene passes `isSelfPlayer: true` (Task 2 already changed this — confirm it survives the wiring edits here)
      - bindHandlers' `players.onAdd` callback still reads `player.anim_state` / `player.sprite_override` from PlayerState for remote rendering per RESEARCH §"Pitfall 8" (existing per-instance `$(player).onChange` already fires for the new fields — verify nothing in the renderer chokes on the new values)
    </behavior>
    <action>
      In `apps/client/src/scenes/GameScene.ts` — the edits are co-located in 5 regions (W6 accepted-as-is single task):

      1. **Import (line ~79 region, alongside ReconcileEngine):**
         `import { PositionDispatcher } from '../prediction/position-dispatcher.js';`

      2. **Field declaration (line ~131, alongside the existing `reconcile` field):**
         `private positionDispatcher: PositionDispatcher | undefined;`

      3. **Instantiation (line ~700-720, alongside InputDispatcher / ReconcileEngine setup):**
         `this.positionDispatcher = new PositionDispatcher(room, this.prediction, () => this.getCurrentFacing(), () => this.spriteStateMachine.isRunning());`
         — the exact callback wiring depends on the SpriteStateMachine / facing-source API in the scene; the executor reads the surrounding scene state to plug in correctly. The contract: `getFacing` returns the current Direction; `getIsRunning` returns whether the sprite is in a RUN_* pose.

      4. **Per-tick invocation — PRE-RESOLVED HOOK (W4):**
         Inside `override update(_time: number, dt: number)` at line 1152, INSIDE the existing `while (this.simTickAccumulator >= SIM_TICK_MS)` loop body (lines 1166-1206), IMMEDIATELY AFTER the existing `const localSim = this.prediction.predictTick(this.currentLayoutForSim());` call at line 1168, splice in:
         ```
         // [impl->REQ-CLI-04] [impl->REQ-CLI-08] D-05/D-06 — emit position_update at
         // the 30 Hz sim-tick boundary. Idle ticks STILL fire (D-06 steady cadence).
         this.positionDispatcher?.sendTick(_time);
         ```
         Use `_time` (Phaser's monotonic timestamp, already in scope as the first parameter of `override update`), NOT `performance.now()`. The placement is unambiguous: same iteration of the while-loop that runs predictTick, before the loop exits. Co-location with predictTick guarantees one position_update per simulation tick — including idle ticks (D-06).

      5. **Reconnect handler (search for `this.input.setRoom(newRoom)` or equivalent — likely in the room-rebind path):**
         Add `this.positionDispatcher?.setRoom(newRoom);` alongside the existing input.setRoom call.

      PRESERVE the Task 2 edit where `this.reconcile?.onServerSnapshot({ ... }, /* isSelfPlayer */ true)` is passed; confirm the call still compiles.

      In `apps/client/src/net/colyseus-client.ts`:
      - Locate the `joinOrCreate('rebno', { protocol_version: ..., session_token: ... })` call (handshake). Per RESEARCH §"Pitfall 4" the version is bumped to 4 alongside the protocol package's PROTOCOL_VERSION constant. PREFER to source the value as `protocol_version: PROTOCOL_VERSION` (imported from `@rebno/protocol`) over a hardcoded literal — this prevents future drift between the protocol package and the client. If the call currently uses a literal `3`, replace with `PROTOCOL_VERSION` and add the import. If the call already uses `PROTOCOL_VERSION` (sourced from the bumped constant), no source change is needed — the bump in Plan 01 took effect.
      - In `bindHandlers` (lines 237-258), confirm the per-player `$(player).onChange(...)` callback reads through any consumers of the new `player.anim_state` / `player.sprite_override` fields — but DO NOT add a new renderer wiring for sprite_override. Per RESEARCH §"set_sprite_override schema" the override is shipped as a STUB in 06.7 (handler + schema + state field; no consumer wiring in renderer because special poses are post-MVP). Document this with a single-line comment in bindHandlers near the player onChange callback: `// 06.7: sprite_override is stubbed — renderer consumer added when PAR-* phases ship the special-pose sprites.`
      - For `anim_state`, similarly: the existing facing-based sprite selection (06.4 D-58c precedent) already covers the 16 base poses; sending `anim_state` on the wire is forward-compat for future poses. No renderer change required in 06.7 per the PATTERNS.md §"Pitfall 8" note. Verify with a comment.

      GameScene test (`apps/client/src/__test__/game-scene.test.ts`):
      - Update the existing mock at lines ~68-92 (`reconcileInstances` + `ReconcileEngine` mock) to also include a PositionDispatcher mock if the test exercises that code path. If the existing game-scene.test.ts does not touch the dispatcher scheduling, this update is a no-op — just confirm the test still passes after the GameScene wiring edits.
      - If the test breaks because the new positionDispatcher field is undefined under the mock, add a corresponding `vi.mock('../prediction/position-dispatcher.js', ...)` block mirroring the existing ReconcileEngine mock.
    </action>
    <verify>
      <automated>cd apps/client && pnpm exec vitest run src/__test__/game-scene.test.ts src/__test__/colyseus-client.test.ts</automated>
    </verify>
    <done>GameScene instantiates + schedules PositionDispatcher inside the existing 30 Hz `override update` accumulator loop (lines 1166-1206), immediately after predictTick at line 1168; reconnect routes new room to the dispatcher; handshake sends protocol_version=4; bindHandlers documented for the stubbed sprite_override path; existing game-scene + colyseus-client tests still pass (with mock updates if needed).</done>
    <acceptance_criteria>
      - Source assertion: `grep -n "PositionDispatcher" apps/client/src/scenes/GameScene.ts` matches at least 2 lines (import + instantiation)
      - Source assertion (W4 — scheduler hook placement): `grep -nE "positionDispatcher\??\.sendTick\(_time\)" apps/client/src/scenes/GameScene.ts` matches at least one line; the surrounding context (read with offset/limit) shows the call lives INSIDE the `while (this.simTickAccumulator >= SIM_TICK_MS)` loop body, after the `this.prediction.predictTick(...)` call. Specifically: `awk '/override update\(_time/,/^\s*\}$/' apps/client/src/scenes/GameScene.ts | grep -c "positionDispatcher"` ≥ 1
      - Source assertion: `grep -nE "positionDispatcher.*setRoom" apps/client/src/scenes/GameScene.ts` matches at least one line (reconnect wired)
      - Source assertion: handshake value is 4 or sourced from PROTOCOL_VERSION — either `grep -nE "protocol_version: ?4" apps/client/src/net/colyseus-client.ts` matches, OR `grep -nE "protocol_version: ?PROTOCOL_VERSION" apps/client/src/net/colyseus-client.ts` matches AND `grep -n "PROTOCOL_VERSION" packages/protocol/src/version.ts | grep -c '= 4'` ≥ 1 (Plan 01 already verified the constant)
      - Source assertion: NO literal `protocol_version: 3` remains in apps/client — `grep -rn "protocol_version: ?3" apps/client/src` returns 0 matches
      - Source assertion: 06.7 sprite_override stub comment exists — `grep -nE "06.7.*sprite_override.*stub" apps/client/src/net/colyseus-client.ts` matches at least one line
      - Behavior assertion: `pnpm -C apps/client exec vitest run src/__test__/game-scene.test.ts src/__test__/colyseus-client.test.ts` exits 0
      - Regression assertion: `pnpm -C apps/client exec vitest run` (full client suite) exits 0
      - Build assertion: `pnpm -C apps/client exec tsc --noEmit` exits 0
      - Trace assertion: `pnpm trace:check` does not regress REQ-CLI-04 / REQ-CLI-08 stages
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | client → Colyseus `room.send('position_update', ...)` | Client is the source of its own position truth (per D-13 carve-out); server zod-validates the envelope only |
  | GameScene → ReconcileEngine | INTRA-process; no untrusted data. Self-path is now provably no-op; remote path preserved (W7 lock) |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-08 | Tampering (regression) | A future refactor accidentally re-enables ReconcileEngine.onServerSnapshot for self (re-introducing the diagonal-stop ~10 px drift bug from operator UAT 2026-05-16) | mitigate | The D-04 positive-guard test in `reconciler.test.ts` asserts `setPosition` + `tweenTo` are NEVER called when `isSelfPlayer=true`. The reconciler.ts file header documents the invariant. A future revert would either fail the test or require deleting the test (which is auditable). RESEARCH §"Pitfall 1". |
  | T-06.7-08b | Tampering (regression) | A future refactor accidentally collapses the remote-player branch of onServerSnapshot to a no-op, breaking remote-player rendering that the 06.7 operator UAT does not exercise | mitigate | W7 lock: remote-path lerp/threshold tests in `reconciler.test.ts` are RETAINED with explicit `isSelfPlayer=false`. Removing them would require deleting visible test cases — auditable. `DIVERGENCE_THRESHOLD_PX` constant is intentionally NOT deprecated. |
  | T-06.7-09 | Tampering (Phaser race) | An in-flight Phaser tween targeted at the local sprite races with predictor.predictTick's setPosition write, producing visual judder | mitigate / accept | Per RESEARCH §"Pitfall 6": if any GameScene code still calls `tweenTo` against the local sprite outside the now-noop reconciler, add a defensive `scene.tweens.killTweensOf(localSprite)` before the predictor write. If no such call path exists (the only previous caller was the reconciler self-path, now removed), the race is gone by construction. Task 3 documents the choice in a code comment. |
  | T-06.7-10 | DoS (client-side) | Bug in scene scheduler causes PositionDispatcher.sendTick to fire faster than 30 Hz, exceeding server rate-limit (Plan 02 token bucket = 35 tokens/sec / burst 60) | mitigate (server-side) | Server-side `rateLimitOrDrop` drops the excess; on the client, the dispatcher inherits the scene's 30 Hz scheduler — bypassing that scheduler is the only way to overshoot, and that would require a bug, not an exploit. Operator dashboard plots `rate_limit_dropped` for `msg_type='position_update'` per RESEARCH §"Pitfall 3" so a runaway client is observable. The PRE-RESOLVED hook at GameScene.ts:1166-1206 anchors the dispatcher to the SAME accumulator loop that drives predictTick — eliminating timer-drift as a misfire vector. |
  | T-06.7-11 | Tampering | A malicious client constructs a Direction value not in the FACING_OCTANT enum and passes it to `packAnimState`, producing `NaN` or `undefined` on the wire | mitigate | TypeScript types restrict the call site; zod on the server rejects out-of-enum facing. Even if a hostile fork bypasses TypeScript, the zod gate on the server (Plan 02) is the actual security boundary. Client-side packing produces a number (or `undefined | 0x08` which is `NaN`), which zod's `.int().min(0).max(255)` rejects. |

  **Doctrine cross-reference (D-13):** Plan 04 codifies the trust-model split in CLAUDE.md. This plan implements the client side of that split — self-player position lives on the client; the server is a write-through store. The D-04 positive guard in reconciler.test.ts is the regression backstop. The W7 remote-symmetry lock prevents an unrelated remote-rendering regression slipping in under the cover of the self-flip.
  </threat_model>

  <verification>
  - `pnpm -C apps/client exec vitest run` green for the full client unit suite (position-dispatcher.test.ts + reconciler.test.ts + game-scene.test.ts + colyseus-client.test.ts + others)
  - `pnpm -C apps/client exec tsc --noEmit` clean
  - `grep -rn "protocol_version: ?3" apps/client/src` returns 0 matches (no v3 literal remnant)
  - D-04 positive guard test present and passing
  - D-06 idle-cadence test present and passing
  - D-57b + D-58b retirement tombstones present in reconciler.test.ts; remote-path lerp/threshold tests retained with `isSelfPlayer=false`
  - PositionDispatcher.sendTick wired inside the 30 Hz accumulator loop at GameScene.ts:1166-1206, not on a new timer
  - `pnpm trace:check` does not regress REQ-CLI-04 / REQ-CLI-08
  </verification>

  <success_criteria>
  - Client streams c2s.position_update at the sim-tick cadence with all 9 fields correctly populated and int16-quantized, including idle frames (D-06)
  - Self-player reconciliation is provably absent — D-04 acceptance criterion satisfied
  - Remote-player reconciliation behavior is preserved — W7 lock satisfied
  - Phase 06.7-02's two-client integ (when run against this client) shows the end-to-end roundtrip with diagonal-stop drift NOT manifesting
  - Plan 04 operator UAT is unblocked (the full client+server stack runs end-to-end on staging)
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-SUMMARY.md`. Capture:
  - Confirmation that the GameScene scheduler invocation reuses the existing 30 Hz boundary at lines 1166-1206 (no new timer) — record the actual post-edit line number for the `positionDispatcher?.sendTick(_time)` call.
  - Whether colyseus-client.ts now sources `protocol_version` from the constant (preferred) or a literal (acceptable but flag for cleanup).
  - Whether the Phaser tweens.killTweensOf defensive call was added or determined unnecessary (with line refs).
  - The list of D-57b/D-58b tests removed (file:line refs to the deletion) so a future archaeologist sees the deliberate retirement.
  - Confirmation that the remote-path lerp/threshold tests are retained with explicit `isSelfPlayer=false` (W7 lock).
  - Confirmation that the D-06 idle-cadence guard test passes against the implementation (the dispatcher does NOT skip idle frames).
  </output>
  </content>
  </invoke>
  ---
  #### 06.7-04-PLAN.md
  ---
  phase: 06.7
  plan: 04
  type: execute
  wave: 3
  depends_on:
    - 06.7-02
    - 06.7-03
  files_modified:
    - CLAUDE.md
    - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md
    - .planning/STATE.md
    - .planning/ROADMAP.md
  autonomous: false
  requirements:
    - REQ-CLI-08
    - REQ-SRV-03
    - REQ-SRV-04
    - REQ-CLI-04
  must_haves:
    truths:
      - "CLAUDE.md Hard Rule 1 contains the D-13 narrow movement carve-out — clients are authoritative for {position, velocity, facing, anim_state}; server stays authoritative for chat origin, inventory, room transitions, persistence"
      - "Scores/combat are NOT in the Hard Rule 1 example list (REBNO has none — per D-13)"
      - "Hard Rule 1 carries the literal [doc->REQ-SRV-04] tag (W5 resolution — REQ-SRV-04 = 'Server is authoritative — clients send intent, server emits state', the doctrine anchor in traceable-reqs.toml)"
      - "06.7-HUMAN-UAT.md exists and is runnable by an operator on staging — closes diagonal-stop drift + simulated-packet-delay hitching + two-player smoke + idle-cadence 30 Hz network panel observation (W3 Test 6)"
      - "Operator UAT signed off (checkpoint:human-verify gate passes)"
      - "STATE.md reflects phase completion; ROADMAP.md Phase 06.7 entry shows plans as [x] and the phase as ready for verification"
    artifacts:
      - path: "CLAUDE.md"
        provides: "Hard Rule 1 narrow movement carve-out (D-13) tagged [doc->REQ-SRV-04]"
        contains: "CLIENT-AUTHORITATIVE"
      - path: ".planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md"
        provides: "Operator UAT script — diagonal-stop drift + dropped-packet hitching + two-player smoke + D-06 idle-cadence network-panel observation"
        contains: "Diagonal-stop"
      - path: ".planning/STATE.md"
        provides: "Phase 06.7 status update"
        contains: "06.7"
      - path: ".planning/ROADMAP.md"
        provides: "Phase 06.7 plans marked complete + UAT signed-off"
        contains: "06.7-01-PLAN.md"
    key_links:
      - from: "CLAUDE.md §Hard Rules Rule 1"
        to: "client-trust movement carve-out paragraph"
        via: "doctrine update per D-13 + literal [doc->REQ-SRV-04] tag"
        pattern: "CLIENT-AUTHORITATIVE"
      - from: "06.7-HUMAN-UAT.md"
        to: "Operator sign-off section"
        via: "explicit pass/fail per test (1-6)"
        pattern: "Sign-off"
  ---

  <objective>
  Land the doctrine change codifying the trust split (CLAUDE.md Hard Rule 1 narrow carve-out per D-13, anchored with the literal `[doc->REQ-SRV-04]` tag per W5 resolution), ship the operator UAT script that closes the two motivating bugs from operator UAT 2026-05-16 (diagonal-stop drift + dropped-packet hitching) AND adds a Test 6 idle-cadence network-panel observation (W3 — the human counterpart to the automated D-06 idle-cadence guard in Plan 03), run the UAT on staging (operator checkpoint), and update STATE/ROADMAP to reflect phase completion.

  Purpose: D-13 / D-12 — doctrine catches up to the implementation; staging-only hard-cut deployment validates the new model in place; rollback path documented (D-14 = `/gsd-undo` on phase commits).
  Output: 1 doctrine edit (with W5-pinned tag) + 1 new UAT script (with W3-added Test 6) + 1 operator checkpoint task + STATE/ROADMAP updates. The CLAUDE.md edit IS a Phase 06.7 deliverable per the planning-context "CLAUDE.md edit task IS a deliverable" instruction.
  </objective>

  <execution_context>
  @$HOME/.claude/get-shit-done/workflows/execute-plan.md
  @$HOME/.claude/get-shit-done/templates/summary.md
  </execution_context>

  <context>
  @.planning/STATE.md
  @.planning/ROADMAP.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-02-PLAN.md
  @.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-03-PLAN.md
  @CLAUDE.md
  @.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md

  <interfaces>
  <!-- Doctrine target — CLAUDE.md Hard Rules §1: -->

  Current text (locate via Grep — `grep -n "Server-authoritative" CLAUDE.md`):
  > 1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.

  Replacement per CONTEXT D-13 + PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out":
  > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent
  >    for everything EXCEPT player movement. Server emits state. Never trust client
  >    identity, chat origin, inventory, room transitions, or persistence — these
  >    are server-authoritative. **Movement (position, velocity, facing, anim_state)
  >    is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported
  >    state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger)
  >    is deferred to Phase 06.8. [doc->REQ-SRV-04]

  Notes:
  - D-13 LOCKED scope: ONE paragraph edit. No tabular restructure (deferred).
  - D-13 LOCKED scope: DROP "scores, combat" from the rule example list — REBNO has none.
  - **W5 RESOLVED:** the literal tag is `[doc->REQ-SRV-04]`. Confirmed by inspection of `traceable-reqs.toml` line 132-134: `REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"` — this is the EXACT doctrine anchor for Hard Rule 1. The executor does NOT need to grep or choose; it is pinned here.

  <!-- UAT precedent — `.planning/phases/06.1-.../06.1-HUMAN-UAT.md` structure: -->
  - ## Preconditions (staging URL, accounts, browser)
  - ## Operator Test 1, Test 2, ... (numbered steps + expected vs observed columns)
  - ## Sign-off section (operator name, date, pass/fail, notes)
  </interfaces>
  </context>

  <tasks>

  <task type="auto">
    <name>Task 1: CLAUDE.md Hard Rule 1 narrow movement carve-out (D-13) — tagged [doc->REQ-SRV-04]</name>
    <files>CLAUDE.md</files>
    <read_first>
      - CLAUDE.md (FULL — Hard Rules section, specifically Rule 1; current text is "1. **Server-authoritative.** Clients send intent..."; check the broader context to ensure no other Hard Rule depends on the current phrasing)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out (doctrine — D-13)" (exact replacement text)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-13 (LOCKED scope: paragraph edit only, no table restructure, drop scores/combat)
      - traceable-reqs.toml (verified by the planner at revision time — REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"; this IS the Hard Rule 1 doctrine anchor)
    </read_first>
    <action>
      Locate Hard Rule 1 in CLAUDE.md (line currently reading `1. **Server-authoritative.** Clients send intent. Server emits state. Never trust client positions, scores, chat origin.`).

      Replace the rule body with the exact text below (also reproduced in PATTERNS.md §"CLAUDE.md Hard Rule 1 carve-out"). **The literal `[doc->REQ-SRV-04]` tag is REQUIRED at the end of the paragraph (W5 resolution):**

      > 1. **Server-authoritative (with narrow movement carve-out).** Clients send intent for everything EXCEPT player movement. Server emits state. Never trust client identity, chat origin, inventory, room transitions, or persistence — these are server-authoritative. **Movement (position, velocity, facing, anim_state) is CLIENT-AUTHORITATIVE as of Phase 06.7 — server stores client-reported state and broadcasts to peers.** Anti-cheat (illegal-position fall trigger) is deferred to Phase 06.8. [doc->REQ-SRV-04]

      The tag MUST be the literal `[doc->REQ-SRV-04]`. The planner verified at revision-iteration-1 time that `traceable-reqs.toml` line 132-134 declares `REQ-SRV-04 = "Server is authoritative — clients send intent, server emits state"` with `required_stages = ["doc", "impl", "unit", "int"]` — REQ-SRV-04 is the doctrine anchor for Hard Rule 1, no further grep / choice required. Do NOT substitute `REQ-SRV-03` (which anchors "apps/server runs Node 22 + Colyseus 0.17 with one Room class") nor `REQ-CLI-04` (which anchors client-prediction discipline) — those have different titles and would muddle traceability.

      Do NOT modify any other Hard Rule. Do NOT add a table. Do NOT add Phase 06.8 details beyond the one-sentence deferral reference already in the paragraph.

      Run `pnpm trace:check` post-edit to confirm the new tag is recognised and no `undeclared_id` finding is introduced (REQ-SRV-04 is already declared in traceable-reqs.toml so the doc-stage row should now show `doc` covered).
    </action>
    <verify>
      <automated>grep -nE "CLIENT-AUTHORITATIVE" CLAUDE.md && grep -nE "\[doc->REQ-SRV-04\]" CLAUDE.md && pnpm trace:check</automated>
    </verify>
    <done>Hard Rule 1 reads with the D-13 carve-out language; the literal `[doc->REQ-SRV-04]` tag anchors the paragraph; `pnpm trace:check` passes; REQ-SRV-04 doc-stage row shows covered.</done>
    <acceptance_criteria>
      - Source assertion: `grep -nE "Server-authoritative \(with narrow movement carve-out\)" CLAUDE.md` matches exactly one line
      - Source assertion: `grep -n "CLIENT-AUTHORITATIVE" CLAUDE.md` matches at least one line
      - Source assertion: `grep -n "position, velocity, facing, anim_state" CLAUDE.md` matches the carve-out scope sentence
      - Source assertion: `grep -n "scores" CLAUDE.md | grep -i "rule" -B5 | grep -c "Hard Rule 1"` returns 0 — i.e. the word "scores" no longer appears in Hard Rule 1 (per D-13 drop). (Scores may appear elsewhere in the file; the assertion scopes to Hard Rule 1 context. If grep glue is fragile, the simpler check: read Hard Rule 1 directly and confirm "scores" is absent.)
      - Source assertion (W5 — pinned tag): `grep -nE "\[doc->REQ-SRV-04\]" CLAUDE.md` matches at least one line in the Hard Rule 1 paragraph
      - Trace assertion: `pnpm trace:check` exits 0 with no `undeclared_id` or `parse_error` findings for the new tag; REQ-SRV-04 doc-stage row is covered by the new CLAUDE.md tag
    </acceptance_criteria>
  </task>

  <task type="auto">
    <name>Task 2: Create 06.7-HUMAN-UAT.md operator script (with W3 Test 6 — idle-cadence network-panel observation)</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <read_first>
      - .planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md (FULL — structure precedent: preconditions, numbered operator tests, sign-off section)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §"Motivating bugs" (D-02 — diagonal-stop ~10 px shift; dropped-packet hitching) + §D-06 (steady 30 Hz cadence; idle frames still emit)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-PATTERNS.md §"06.7-HUMAN-UAT.md"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-RESEARCH.md §"Validation Architecture" row "REQ-CLI-08 manual-only"
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-VALIDATION.md (the new row: "D-06 idle cadence guard — PositionDispatcher.sendTick emits valid payload when getLocalState returns {vx:0, vy:0}" — the automated half lives in Plan 03 Task 1; this human-UAT Test 6 is the manual half)
    </read_first>
    <action>
      Create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`. Tag the file `[doc->REQ-CLI-08]` near the top. Structure mirrors `.planning/phases/06.1-gap-closure-d-39-d-46-uat-2026-05-11/06.1-HUMAN-UAT.md`:

      ### Preconditions
      - Staging deployed with phase 06.7 commits (server + client)
      - PROTOCOL_VERSION = 4 confirmed live
      - Two browser sessions (Chrome) with two distinct operator accounts authed
      - Chrome DevTools Network conditions panel accessible (for packet-delay simulation AND for Test 6 frame-count observation)
      - Operator IP allowlisted on staging (per memory note 2026-05-08 — re-verify session IP)

      ### Test 1: Diagonal-stop drift bug closed (D-02a)
      Reproduction steps from the original 2026-05-16 UAT:
      - Step 1: log in, navigate to BNCentral spawn
      - Step 2: press W+D (UR diagonal) for 2 seconds
      - Step 3: release BOTH keys simultaneously
      - Expected: sprite stops at its current pixel position with NO horizontal shift (no ~10 px snap right or left)
      - Observed: ___ (operator fills in)
      - Pass/Fail: ___

      ### Test 2: Dropped-packet hitching closed (D-02b)
      - Step 1: open Chrome DevTools → Network → Throttling → "Slow 3G" (or custom: 5% packet loss / 150ms RTT)
      - Step 2: hold W (run north) for 5 seconds
      - Expected: continuous smooth movement (each position_update is self-describing per the 06.7 model; single dropped packet invisible at 30 Hz)
      - Observed: ___
      - Pass/Fail: ___
      - Note: if hitching appears, capture HAR + screenshot; this is the regression-on-this-bug signal.

      ### Test 3: Two-player smoke (REQ-CLI-08 hard milestone — regression confirm)
      - Step 1: Operator A logs in on Browser 1; Operator B logs in on Browser 2; both join BNCentral
      - Step 2: A and B see each other's sprites
      - Step 3: A walks in a square (W, D, S, A for 1 sec each); B observes A moving smoothly (no rubber-band, no diagonal-stop drift visible on remote view)
      - Step 4: B walks in a square; A observes same smoothness
      - Step 5: Both send chat messages; chat appears with correct origin attribution
      - Expected: all four observation checks pass
      - Observed: ___
      - Pass/Fail: ___

      ### Test 4: PROTOCOL_VERSION 3 client rejected (deploy-ritual verification — D-12)
      - Step 1: Open the staging URL with `?protocol_version_override=3` (or however the dev-tooling allows forcing a downgraded handshake — confirm the override mechanism with the codebase before authoring; if no override exists, skip this test and note that automated integ in Plan 02 covers it)
      - Step 2: Attempt to join
      - Expected: connection rejected with PROTOCOL_VERSION_MISMATCH (close 4400)
      - Observed: ___
      - Pass/Fail: ___

      ### Test 5: Rollback drill (D-14 — `/gsd-undo` works)
      - Step 1: Capture current staging commit SHA
      - Step 2: Do NOT actually run /gsd-undo (this is a paper drill); verify the runbook references the commit SHAs of plans 01-04 as the rollback target
      - Expected: documented rollback target is the 4 phase commits
      - Observed: ___
      - Pass/Fail: ___

      ### Test 6: Idle-cadence steady 30 Hz network confirm (D-06 — REQ-CLI-04 — W3 addition)
      Closes the manual half of the D-06 validation row from 06.7-VALIDATION.md. The automated half is the unit test in Plan 03 Task 1 (`apps/client/src/__test__/position-dispatcher.test.ts` — "D-06 06.7 — sendTick emits a valid payload when player is idle").
      - Step 1: log in to BNCentral; do NOT press any movement keys
      - Step 2: open Chrome DevTools → Network panel → filter for WS frames on the Colyseus connection (the websocket carrying `position_update`)
      - Step 3: leave the player idle for 5 seconds, watching the websocket frames panel
      - Expected: approximately 150 `position_update` frames over the 5-second window (30 Hz × 5 s = 150 frames; acceptable range ~120–180 to allow for browser scheduler jitter). Each frame carries the same x/y and vx=0/vy=0. The cadence is STEADY — no gaps longer than ~100 ms.
      - Observed (rough frame count over 5 s): ___
      - Observed (any gaps > 100 ms?): ___
      - Pass/Fail: ___
      - Note: if frame count is dramatically lower (e.g. < 30 over 5 s), the dispatcher is gating on movement — a D-06 violation; capture the websocket trace + the dispatcher source diff and route to gap-closure.

      ### Sign-off
      - Operator: ___
      - Date: ___
      - Overall pass/fail: ___
      - Phase 06.7 ready for `/gsd-verify-work`: yes / no
      - Open issues (if any): ___

      Tag the file body with at least one `[doc->REQ-CLI-08]` AND one `[doc->REQ-CLI-04]` (the D-06 idle-cadence row in 06.7-VALIDATION.md maps to REQ-CLI-04; Test 6 closes the manual half of that row). The file is the manual-only complement to the automated CLI-08 integ from Plan 02 + the D-06 idle-cadence unit from Plan 03 Task 1 — together they close REQ-CLI-08 and the D-06 row of REQ-CLI-04 for this phase.
    </action>
    <verify>
      <automated>test -f .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -cE "^### Test [1-6]:" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -n "doc->REQ-CLI-08" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -n "doc->REQ-CLI-04" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
    </verify>
    <done>Operator UAT script exists with 6 tests (1-5 original + 6 = W3 idle-cadence network-panel observation) + sign-off section; both REQ-CLI-08 and REQ-CLI-04 doc tags present; structure mirrors the 06.1 precedent.</done>
    <acceptance_criteria>
      - File exists: `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`
      - Source assertion: `grep -cE "^### Test [1-6]:" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` returns ≥ 6 (Test 1..Test 6 sections present — W3 Test 6 included)
      - Source assertion: `grep -n "Diagonal-stop" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion: `grep -n "Dropped-packet\|packet loss\|packet delay" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion: `grep -n "Two-player\|two-player" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Source assertion (W3 — Test 6 idle-cadence observation): `grep -nE "Idle-cadence|idle.*5 sec|30 position_update frames|150 .*frames" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line in Test 6
      - Source assertion: `grep -n "Sign-off" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches at least one line
      - Tag assertion: `grep -c "doc->REQ-CLI-08" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` ≥ 1
      - Tag assertion (W3 — REQ-CLI-04 D-06 row anchored): `grep -c "doc->REQ-CLI-04" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` ≥ 1
      - Trace assertion: `pnpm trace:check` exits 0
    </acceptance_criteria>
  </task>

  <task type="checkpoint:human-verify" gate="blocking">
    <name>Task 3: Operator UAT checkpoint — run 06.7-HUMAN-UAT.md on staging and sign off</name>
    <files>.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</files>
    <read_first>
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md (just authored in Task 2)
      - .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-CONTEXT.md §D-12 (hard cut on staging) §D-14 (rollback path)
    </read_first>
    <action>
      Pause for operator. The operator opens 06.7-HUMAN-UAT.md, executes the SIX tests on staging in sequence (Tests 1-5 + the W3 Test 6 idle-cadence observation), records Observed + Pass/Fail per test, fills in the Sign-off section, and commits the updated UAT file. Claude does NOT automate this task — the entire point is operator-feel verification of the two motivating bugs (D-02a diagonal-stop drift + D-02b dropped-packet hitching) that no automated test captures, plus the D-06 idle-cadence behavior observed via DevTools (Test 6) which the unit test in Plan 03 cannot observe on a real browser. See <what-built> and <how-to-verify> below for the operator-facing instructions.
    </action>
    <what-built>
      Phase 06.7 ships the client-trust movement model end-to-end:
      - Plan 01: protocol surface (cPositionUpdateSchema, cSetSpriteOverrideSchema, packAnimState/unpackAnimState, PlayerState fields, PROTOCOL_VERSION=4)
      - Plan 02: server handlers + tickLoop platforms-only + protocol v4 handshake test
      - Plan 03: PositionDispatcher (with D-06 idle-cadence guard test) + reconciler self-noop (D-04) + protocol_version=4 handshake + remote-symmetry preserved (W7 lock)
      - Plan 04 Task 1: CLAUDE.md Hard Rule 1 narrow carve-out (D-13) — tagged `[doc->REQ-SRV-04]` (W5)
      - Plan 04 Task 2: Operator UAT script — 6 tests including Test 6 (D-06 idle-cadence — W3)

      Pre-checkpoint sanity: ensure the latest staging deploy includes plans 01+02+03 commits BEFORE running this checkpoint. If staging is behind, kick off a deploy first.
    </what-built>
    <how-to-verify>
      1. Open `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md`.
      2. Execute Tests 1-6 in sequence on staging.
         - For each test, record the Observed and Pass/Fail values.
         - Test 6 specifically asks for a websocket frame count over 5 idle seconds — leave the player idle and watch the DevTools Network → WS panel. Expected ~150 frames (30 Hz × 5 s); acceptable 120–180.
         - If ANY test fails: do NOT sign off; capture artifacts (HAR, screenshot, console log); document in the UAT file's "Open issues" section; bring the failure back to the planner via gap-closure flow.
         - If all tests pass: fill in the Sign-off section (operator name, date, "Pass") and commit the updated UAT file.
      3. After sign-off, confirm:
         - `pnpm trace:check` passes (and REQ-SRV-04 doc row now shows the CLAUDE.md tag covering it)
         - `pnpm -r test` (full workspace test) is green — including the new D-06 idle-cadence unit
         - `pnpm -r typecheck` is clean
         - The four phase commits are pushed to staging branch
      4. Type "approved" (or describe failures + recommended next step).
    </how-to-verify>
    <verify>
      <automated>grep -nE "Overall pass/fail: (Pass|pass|PASS)" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md && grep -nE "Operator: \S" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md</automated>
    </verify>
    <done>Operator has filled in the Sign-off section of 06.7-HUMAN-UAT.md with their name + date + an overall Pass; all six tests recorded as Pass or with documented open issues that are non-blocking for this phase; commit landed.</done>
    <resume-signal>Type "approved" to mark UAT passed and advance to Task 4 (STATE/ROADMAP wrap), or paste failure details + recommended next step (gap-closure plan / rollback / other).</resume-signal>
    <acceptance_criteria>
      - Source assertion: `grep -nE "Overall pass/fail: (Pass|pass|PASS)" .planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-HUMAN-UAT.md` matches the post-sign-off line
      - Source assertion: every `### Test N:` block (N=1..6) has a filled-in `Observed:` value (not just the `___` placeholder) — visual scan of the UAT file
      - Behavior assertion: operator typed "approved" in the chat or paste-back, confirming gate passed
      - Regression assertion: `pnpm -r test && pnpm -r typecheck && pnpm trace:check` all exit 0 at sign-off time
    </acceptance_criteria>
  </task>

  <task type="auto">
    <name>Task 4: Update STATE.md + ROADMAP.md to reflect Phase 06.7 completion</name>
    <files>.planning/STATE.md, .planning/ROADMAP.md</files>
    <read_first>
      - .planning/STATE.md (current phase status section)
      - .planning/ROADMAP.md (Phase 06.7 entry @ lines 200-214; the existing plans list is "TBD")
      - The completed 06.7-01/02/03/04 SUMMARY files (produced by each plan's <output> stanza)
    </read_first>
    <action>
      Update `.planning/STATE.md`:
      - Move Phase 06.7 to a "completed" or "verification-pending" status as appropriate to the project's STATE convention.
      - Record the four phase commit SHAs (one per plan) with one-line summaries.
      - Note the operator UAT pass-date from Task 3.
      - Add the deferred-to-06.8 items: server-side legality check, speed-cap, walkable-grid sanity, fall reset (per CONTEXT D-11 + Deferred Ideas).
      - Add the deferred-to-06.9-cleanup items: c2s.input + heldInputs removal (per RESEARCH §Open Question 3).

      Update `.planning/ROADMAP.md`:
      - In the Phase 06.7 section (around line 200-214), replace the "TBD" plan list with the four phase plans, all checked off:
        ```
        Plans:
        - [x] 06.7-01-PLAN.md — Wave 1 — Protocol surface: cPositionUpdateSchema + cSetSpriteOverrideSchema + packAnimState + PlayerState fields + PROTOCOL_VERSION 3→4 (REQ-SRV-03, REQ-CLI-04, REQ-CLI-08)
        - [x] 06.7-02-PLAN.md — Wave 2 — Server: position_update + set_sprite_override handlers + tickLoop platforms-only + protocol-v4 handshake integ (REQ-SRV-03, REQ-SRV-14, REQ-CLI-08)
        - [x] 06.7-03-PLAN.md — Wave 2 — Client: PositionDispatcher (+D-06 idle-cadence guard) + reconciler self-noop (D-04) + remote symmetry preserved (W7) + protocol_version=4 handshake (REQ-CLI-04, REQ-CLI-08)
        - [x] 06.7-04-PLAN.md — Wave 3 — CLAUDE.md Hard Rule 1 carve-out (D-13, [doc->REQ-SRV-04]) + operator HUMAN-UAT (6 tests incl. D-06 idle-cadence) + STATE/ROADMAP wrap (REQ-CLI-08, REQ-SRV-03, REQ-SRV-04, REQ-CLI-04)
        ```
      - Update **Plans:** TBD line to `**Plans:** 4 plans (all complete, operator UAT signed off YYYY-MM-DD)`.
      - DO NOT mark the phase as `[x]` at the roadmap top-level until `/gsd-verify-work 06.7` confirms — the wrap-up task stops at "complete-pending-verify".

      Add a brief line near the bottom of the ROADMAP Phase 06.7 entry noting the future 06.8 dependency: "Anti-cheat (illegal-position fall trigger + speed-cap + walkable-grid sanity) deferred to candidate Phase 06.8 per CONTEXT D-11."
    </action>
    <verify>
      <automated>grep -cE "^\- \[x\] 06\.7-(01|02|03|04)-PLAN\.md" .planning/ROADMAP.md && grep -n "06.7" .planning/STATE.md | head -5</automated>
    </verify>
    <done>STATE + ROADMAP reflect Phase 06.7 complete-pending-verify; the four plans are checked off in ROADMAP; deferred 06.8 items are documented.</done>
    <acceptance_criteria>
      - Source assertion: `grep -cE "^\- \[x\] 06\.7-(01|02|03|04)-PLAN\.md" .planning/ROADMAP.md` returns exactly 4
      - Source assertion: `grep -n "operator UAT signed off" .planning/ROADMAP.md` matches a line in the Phase 06.7 section
      - Source assertion: `grep -n "06.7" .planning/STATE.md` matches at least one line (phase reflected in current state)
      - Source assertion: `grep -n "candidate Phase 06.8" .planning/ROADMAP.md` matches the deferred-item annotation
      - Trace assertion: `pnpm trace:check` exits 0 (no doc-stage regression)
    </acceptance_criteria>
  </task>

  </tasks>

  <threat_model>
  ## Trust Boundaries

  | Boundary | Description |
  |----------|-------------|
  | Doctrine (CLAUDE.md) → Future planners / agents | Hard Rule 1 IS the trust-boundary specification; doctrine drift is the long-term Tampering risk |
  | Operator UAT script → Operator | Manual-only path; operator's judgment closes the feel-based regressions automated tests can't |

  ## STRIDE Threat Register

  | Threat ID | Category | Component | Disposition | Mitigation Plan |
  |-----------|----------|-----------|-------------|-----------------|
  | T-06.7-12 | Tampering (doctrine drift) | A future planner accidentally re-asserts "Never trust client positions" in a new doctrine paragraph, contradicting Hard Rule 1's carve-out | mitigate | Hard Rule 1 paragraph explicitly states "CLIENT-AUTHORITATIVE as of Phase 06.7" with a phase reference; `pnpm trace:check` enforces the doc-stage tag exists; the literal `[doc->REQ-SRV-04]` tag anchors the paragraph to the traceability table. Future doctrine edits should NOT re-tag REQ-SRV-04 from a contradictory paragraph; if they do, a reviewer notices the duplication and reconciles. |
  | T-06.7-13 | Repudiation (operator UAT) | Operator signs off without actually running the tests | accept | Solo-developer workflow; the operator is the user. Trust model degrades at the doctrine layer if the operator self-deceives. Mitigation is structural (numbered tests 1-6 with explicit pass/fail per item, including a frame-count observation in Test 6 that is hard to fake without artifacts); no further enforcement. |
  | T-06.7-14 | Information Disclosure (rollback drill) | The phase commit SHAs documented in STATE.md leak in a way that helps attackers — unlikely but possible if the repo accidentally goes public | accept | Per CLAUDE.md Hard Rule 8, the repo stays private through Phase 7. Phase commit SHAs are public-safe (no secrets in commit content); the rollback drill is purely procedural. |

  **Doctrine continuity (D-13 cross-check):** The CLAUDE.md edit in Task 1 closes the doctrine loop opened by Plans 01-03 (which implemented the trust split but did not codify it). Without this edit, a future planner reading only CLAUDE.md would mis-architect (e.g. add server-side position derivation that double-writes player.x). Task 1 is therefore as load-bearing as the implementation plans. **W5 pin:** the tag is `[doc->REQ-SRV-04]`, no executor ambiguity.

  **Operator UAT-driven safety:** Three of the motivating outcomes (D-02a diagonal-stop drift, D-02b dropped-packet hitching, D-06 idle-cadence steady 30 Hz) are operator-feel or browser-observable regressions that no headless automated test captures cleanly. Test 1 + Test 2 + Test 6 close these explicitly; without the UAT, "the bug is closed" / "the cadence holds in a real browser" is unverifiable.
  </threat_model>

  <verification>
  - CLAUDE.md Hard Rule 1 reads with D-13 language and the literal `[doc->REQ-SRV-04]` tag (W5)
  - `pnpm trace:check` passes (no undeclared_id from the new doc tag; REQ-SRV-04 doc-stage row now covered)
  - 06.7-HUMAN-UAT.md present and complete — 6 tests including Test 6 (D-06 idle-cadence, W3)
  - Operator checkpoint (Task 3) returns "approved" — or returns failure detail that gets escalated to gap-closure
  - STATE.md / ROADMAP.md updated post-sign-off
  - `pnpm -r test` + `pnpm -r typecheck` green across the workspace (regression confirmation against the combined Plan 01+02+03 deltas — including the new D-06 idle-cadence unit)
  </verification>

  <success_criteria>
  - Doctrine, implementation, and observability are aligned: CLAUDE.md describes what the code does (with the W5-pinned [doc->REQ-SRV-04] tag); the code does what the operator UAT demands (including the W3 Test 6 idle-cadence observation); the operator UAT confirms the two motivating bugs from operator UAT 2026-05-16 are closed AND that D-06 holds in a real browser
  - /gsd-verify-work 06.7 has everything it needs (4 plans complete, operator UAT signed off, traceable-reqs clean, full test suite green)
  - The deferred-to-06.8 items are documented so a future planner has a starting point
  - The deferred-to-06.9 cleanup items (heldInputs vestigial, cInputSchema vestigial) are also documented
  </success_criteria>

  <output>
  After completion, create `.planning/phases/06.7-network-model-client-trust-fall-trigger/06.7-04-SUMMARY.md`. Capture:
  - Final CLAUDE.md Hard Rule 1 text (exact post-edit form, including the `[doc->REQ-SRV-04]` tag) for archival
  - Operator UAT outcome (date, pass/fail per test 1-6, any captured artifacts including the Test 6 frame-count value)
  - The four phase commit SHAs for the rollback target
  - The deferred items list for 06.8 (anti-cheat) and 06.9-cleanup (heldInputs)
  - A one-line recommendation: ready for /gsd-verify-work 06.7
  </output>
  </content>
  </invoke>
  ## Review Instructions

  Analyze the plans and provide:
  1. **Summary** — one-paragraph assessment
  2. **Strengths** — bullet points
  3. **Concerns** — bullets with severity (HIGH/MEDIUM/LOW)
  4. **Suggestions** — specific improvements
  5. **Risk Assessment** — overall LOW/MEDIUM/HIGH with justification

  Focus on:
  - Missing edge cases or error handling
  - Dependency ordering issues
  - Scope creep / over-engineering
  - Security (the D-11 acceptance of zero server-side anti-cheat is explicit — flag only if you see a NEW security gap unrelated to D-11)
  - Performance (50 CCU, 30 Hz cadence, Colyseus TCP/WS, single Fly.io machine)
  - Whether the plans actually achieve the phase goal: flip self-player movement to client-trust + reconciler no-op for self, fix diagonal-stop drift + dropped-packet hitching, keep remote symmetry

  Output review in markdown.


