# 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
