# Phase 7: Workflow Smoke + Convention Locks - Context

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

<domain>
## Phase Boundary

Lock every HARD-gate convention and substitute-for-CI tooling **before any `.ldtk` file is authored**, so downstream phases (8–12) cannot ship undetectable drift. Phase 7 is risk-front-loading: four ADRs, one preflight script, two hygiene gates, one operator-machine smoke test. **No `.ldtk` content is authored or shipped in this phase** beyond the throwaway `maps/_smoke/synth-8000x6400.ldtk` used for the chunking smoke gate.

Requirements covered: **MAP-02, MAP-15, HYG-01, HYG-02** (4 of 18 v1.1 reqs).

</domain>

<decisions>
## Implementation Decisions

### gridSize ADR

- **D-01:** `gridSize = 4` **unified across all layer types** (IntGrid, Tile, Entity). One mental grid; no per-layer asymmetry. Diverges from the research-default 4/1 split per operator preference for single-grid mental model.
- **D-02:** 44×40 floor pitch maps to **11×10 cells** in the editor. Each LDtk visual cell = 4 px square; one BNO floor tile = an 11×10 block of cells. Mental gear-shift acknowledged — documented in the ADR.
- **D-03:** Entity placement **snaps to 4 px** (not pixel-accurate). Acceptable because original BNO entities (NPCs, teleporters, messageboards, platforms) are tile-aligned in legacy GML — 4 px tolerance is well below visible drift. Phaser sprite-origin offsets (e.g. NaviStandD `bboxBottom=46`) are still applied at runtime; gridSize controls editor snapping only.
- **D-04:** ADR file: `docs/adr/0009-ldtk-gridsize-convention.md`.

### tilesetSourceHash ADR

- **D-05:** Hash algorithm = **sha256** (hex string, 64 chars). Node `crypto` built-in; zero new dependencies; matches era-choice with Ed25519 signing in `tools/room-converter`.
- **D-06:** Placement = **sidecar JSON file**, one per `.ldtk` (e.g. `maps/<world>.ldtk` ↔ `maps/<world>.tileset-hashes.json`). LDtk 1.5.3 `TilesetDef` has NO `customFields[]` array (only `EntityDef`/`LayerDef` do — verified against `LDTK_JSON_SCHEMA.json`); CONTEXT.md v1 claim was incorrect. Sidecar keeps `.ldtk` clean, survives editor round-trips, trivially scriptable. Schema = `{ "<TilesetDef.uid>": "<sha256-hex>" }` per entry. Per-tileset granularity preserved (D-06 intent unchanged). **Resolved 2026-05-20 — operator picked Option B from RESEARCH.md Q1.**
- **D-07:** Drift behavior = **HARD throw + refuse to load**. Loader-init asserts each `TilesetDefinition.tilesetSourceHash` matches the sha256 of the referenced PNG (read from `apps/client/public/atlas-mvp.png` or the resolved `extracted/client-5-8/sprites/...` path). Mismatch → throw with message naming the drifted tileset + expected/got hashes. Matches Ed25519-sig-fail posture (fail-loud, refuse silent degradation).
- **D-08:** ADR file: `docs/adr/0010-ldtk-tileset-source-hash.md`.

### BNCentral Chunk Grid ADR

- **D-09:** Chunk grid = **3×3 GridVania** (9 chunks total). Odd dimension chosen so the **center chunk** owns the BNCentral hub area uninterrupted — no central seam to author across or test transitions through (the highest-value area is the easiest to author, not the hardest).
- **D-10:** Chunk dimensions: cols 2668/2664/2668 px, rows 2132/2136/2132 px (sums = 8000×6400; each chunk dimension is a multiple of 4 to honor gridSize=4). All chunks well under the LDtk ~5000 px editor-lag threshold (deepnight/ldtk #1029, #1073).
- **D-11:** ADR file: `docs/adr/0011-bncentral-gridvania-chunking.md`. Concrete BNCentral conversion is **out of v1.1 scope** (per ROADMAP "Carried out of v1.1 scope" — v1.2 work). Phase 7 only locks the chunk-grid convention so v1.2 inherits a stable target.

### LDtk Version Pin ADR

- **D-12:** Pin file = `tools/ldtk-version.txt`, single-line content (e.g. `1.5.3`). Operator-trust posture: operator updates the file when bumping LDtk locally; `pnpm preflight` reads + echoes the expected version; no programmatic LDtk-install introspection (LDtk is a desktop GUI app with no stable CLI/registry surface for version detection on Windows). Matches the 06.7 client-trust model carve-over.
- **D-13:** ADR file: `docs/adr/0012-ldtk-version-pin.md`.

### `pnpm preflight` Composition (MAP-15)

- **D-14:** Execution model = **serial, fail-fast** (`&&` chain in `package.json` script). First failure stops; operator iterates one check at a time. Collect-all mode rejected — delays signal and lets multiple drift sources mask one another.
- **D-15:** Composition order (top → bottom; each gates the next):
  1. **LDtk version pin assert** — read `tools/ldtk-version.txt`, echo to stdout, no programmatic check beyond file existence + non-empty (D-12).
  2. `pnpm lint:room-layout` — existing; gates "exactly one layout shape per room file" (Pattern A schema-union lint).
  3. `tsx tooling/no-inline-origin.ts` — existing HYG-01 D-63 gate (87 lines, functional, scope=`apps/client/src/render/`).
  4. `pnpm trace:check` — existing; gates `traceable-reqs.toml` coverage.
  5. `pnpm lint:no-req-placeholders` — **new** ~10-line script; drift-locks HYG-02 clean state (D-19).
  6. **Atlas-hash assertion** — **new** lightweight script; reads `tilesetSourceHash` from any `.ldtk` files under `maps/` and compares to sha256 of referenced PNGs. **No-op when `maps/` is empty** (Phase 7 case — activates organically in Phase 8 once `TestBed_001.ldtk` lands).
  7. **Conversion-regression check** — Phase 9 owns real implementation. Phase 7 = **stub that logs `"skipped: pending Phase 9"` and exits 0**. Not a no-op — leaves the slot wired so Phase 9 only has to swap the stub body.
- **D-16:** Documented as MANDATORY in `docs/deploy/LOCAL-DEPLOY.md` before any `flyctl deploy`. Section heading: `## Preflight gate (MANDATORY)`.
- **D-17:** `package.json` entry: `"preflight": "<step1> && <step2> && ... && <step7>"`. No nested scripts file — keep the chain visible in `package.json` so drift requires an explicit edit.

### HYG-01 — D-63 Audit (`tooling/no-inline-origin.ts`)

- **D-18:** **Leave existing script scope unchanged** in Phase 7 (current scope = `apps/client/src/render/`, current regex catches `setOrigin(<digits>/<ident>.<width|height>)`). Already meets HYG-01 success criterion ("catches any inline origin math outside `apps/client/src/render/legacy-origin.ts`"). Phase 7 work = **wire into preflight step 3** (D-15). The `__pivot` discard rule belongs in Phase 8 alongside `LdtkLoader.ts` implementation — that is where the temptation actually exists; pre-emptive Phase 7 broadening would be scope creep.

### HYG-02 — Placeholder Sweep (Drift-Lock, Not One-Shot)

- **D-19:** Sweep result: **zero hits** for `REQ-SRV-XX|REQ-X[^-A-Z0-9]` across `.planning/phases/` and `docs/` (confirmed via grep 2026-05-19). State is already clean.
- **D-20:** Convert from one-shot sweep → **drift-lock script**. New `tools/scripts/lint-no-req-placeholders.mjs` (~10 lines): greps `REQ-SRV-XX|REQ-X[^-A-Z0-9]|REQ-MAP-XX|REQ-HYG-XX|REQ-[A-Z]+-XX` across `docs/`, `.planning/`, `packages/`, `apps/`, `tools/` (matches `traceable-reqs.toml [scan].roots`); exits non-zero on any hit. Wired into preflight step 5 (D-15). Locks the clean state instead of asserting it once.
- **D-21:** `package.json` entry: `"lint:no-req-placeholders": "node tools/scripts/lint-no-req-placeholders.mjs"`.

### Smoke-Test (Success Criterion 5)

- **D-22:** Synthetic zone shape = **3×3 GridVania matching the BNCentral chunk grid exactly** (same dimensions per D-10, same multi-world layout). Each chunk filled with simple BORDERED floor tiles only — no entities, no auto-tile rules, no animated tiles (those are Phase 9 surface). Purpose = exercise the **chunked authoring workflow on the operator machine**, not validate features.
- **D-23:** Pass gate = **subjective operator green-light + screenshot**. Operator authors all 9 chunks, opens + saves a representative subset (at minimum: center chunk + one corner + one edge), records subjective lag impressions. Boolean PASS/FAIL. No stopwatch budget, no automated UI-freeze detection — matches the qualitative nature of "tolerable" (the criterion that exists in ROADMAP).
- **D-24:** Artifact locations:
  - `.ldtk` source → `maps/_smoke/synth-8000x6400.ldtk` (**establishes `maps/` top-level dir now**, ahead of Phase 8's "official" introduction — `_smoke/` prefix marks throwaway content; survives milestone archive as the chunking-decision audit trail)
  - Operator notes + screenshot → `.planning/phases/07-workflow-smoke-convention-locks/SMOKE-NOTES.md`
- **D-25:** Smoke test **does not block** Phase 7 closing if PASS. If FAIL, the chunk grid (D-09/D-10) is re-opened — escalation path documented in SMOKE-NOTES.md.

### Claude's Discretion

- Atlas-hash assertion script implementation details (file path, error message format, exact assertion target — `apps/client/public/atlas-mvp.png` vs resolved extracted PNGs). Planner picks.
- `lint-no-req-placeholders.mjs` exact regex form + exclusion paths. Planner picks; must match `traceable-reqs.toml [scan].roots`.
- ADR file numbering — proposed 0009/0010/0011/0012 sequence; planner adjusts if ADR 0009 already claimed.
- LOCAL-DEPLOY.md section ordering for the new "Preflight gate (MANDATORY)" block.

### Folded Todos

- `.planning/todos/pending/2026-05-16-split-playwright-smoke-into-3rd-workflow-job.md` (score 0.9) — **reviewed but NOT folded**. The todo predates the operator-local `flyctl deploy` doctrine (06.7-09); CI restoration is explicitly carried out of v1.1 scope per STATE.md. Lives on as v1.2+ backlog. See `<deferred>`.

</decisions>

<canonical_refs>
## Canonical References

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

### v1.1 Milestone Artifacts (read first)
- `.planning/REQUIREMENTS.md` §MAP-02, §MAP-15, §HYG-01, §HYG-02 — the four Phase 7 requirements (full text)
- `.planning/ROADMAP.md` "Phase 7" entry — five Success Criteria
- `.planning/STATE.md` — milestone v1.1 status, carry-overs, deferred items
- `.planning/research/v1.1/SUMMARY.md` — research executive summary (12 open questions; Phase 7 resolves #1, partial #5)
- `.planning/research/v1.1/STACK.md` — version pins (LDtk 1.5.3, ajv 8.17.1, chokidar@4, quicktype 23.2.6)
- `.planning/research/v1.1/ARCHITECTURE.md` — integration-point inventory; `maps/` top-level rationale
- `.planning/research/v1.1/PITFALLS.md` — top-5 risks; Pitfalls #1 (D-63), #2 (tileset hash drift), #4 (BNCentral lag) directly drive Phase 7
- `.planning/research/v1.1/FEATURES.md` — feature landscape, must/should/defer split
- `.planning/notes/map-editor-decision.md` — LDtk choice + accepted tradeoffs (PRE-LOCKED; do not re-litigate)
- `.planning/notes/questions.md` — open-questions captured during research

### Coordinate / Origin Convention (D-63)
- `CLAUDE.md` §"Coordinate Conventions (D-63 — Plan 06.4)" — full convention shift spec + bbox-bottom=46 vs sprite-rect-height=48 rationale
- `apps/client/src/render/legacy-origin.ts` — canonical helper `phaserOriginForLegacyPlayerAttached`
- `apps/client/src/__test__/legacy-origin.test.ts` — drift-detection test (atlas-mvp.json vs extracted NaviStandD meta.json)
- `tooling/no-inline-origin.ts` — existing functional 87-line HYG-01 audit script (in-scope as-is per D-18)
- `extracted/client-5-8/sprites/0000-NaviStandD/meta.json` — source-of-truth (width=36, height=48, bboxBottom=46)

### Extracted Constants (load-bearing — never assume otherwise)
- `CLAUDE.md` §"Extracted Constants" — floor tile 44×40 px, tick 30 Hz, view 640×480 px, BNCentral 8000×6400 px
- `extracted/client-5-8/sprites/0023-Tile1/meta.json` — 44×40 source-of-truth
- `extracted/client-5-8/rooms/0058-BNCentral/meta.json` — 8000×6400 source-of-truth
- `docs/extracted-engine/scene-room-model.md` §"Canonical floor tile" — full derivation

### Deploy / Operator Doctrine
- `docs/deploy/LOCAL-DEPLOY.md` — current operator-local `flyctl deploy` flow (Phase 7 adds MANDATORY preflight gate)
- `scripts/client-release.sh` — atomic symlink-swap pattern (Phase 7 doesn't touch; Phase 10 generalizes)
- `apps/server/src/static-assets.ts` — env-resolver pattern (Phase 10 mirrors as `maps-dir.ts`)

### Existing ADRs (reference for new ADR style)
- `docs/adr/0001-client-engine.md` — Phaser choice
- `docs/adr/0004-room-hot-reload.md` — Ed25519 sig + fs.watch
- `docs/adr/0005-deploy-topology.md` — Fly.io single machine
- `docs/adr/0008-canvas-base-resolution.md` — Scale.NONE invariant (constrains MinimapHUD in Phase 11)
- `tools/asset-catalog/scripts/lint-adr.mjs` — ADR lint contract that new ADRs must satisfy

### Traceability
- `traceable-reqs.toml` — 49 v1.0 reqs + (Phase 7 adds) 18 v1.1 entries (MAP-01..16 + HYG-01..02)
- `package.json` §scripts §"trace:check", §"trace:list", §"trace:check:json" — existing wiring

### LDtk Reference (external, no in-repo path yet)
- LDtk JSON Schema: `https://ldtk.io/files/LDTK_JSON_SCHEMA.json` (informs ADR phrasing only; runtime parsing is Phase 8)
- LDtk editor-lag issues: deepnight/ldtk #1029, #1073, #985, #1006 (cited in PITFALLS.md)

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets

- **`tooling/no-inline-origin.ts`** (87 lines, functional) — HYG-01 D-63 audit script already exists from Plan 06.4-07. Phase 7 ONLY wires it into `pnpm preflight`. Does NOT modify the script. `[impl->REQ-CLI-04] [impl->REQ-CLI-08]` tags retained; Phase 7 will add `[impl->REQ-HYG-01]` to the file when wiring.
- **`tools/asset-catalog/scripts/lint-adr.mjs`** — ADR lint contract enforcer. New ADRs 0009/0010/0011/0012 must pass `lint-adr.mjs`. Add per-ADR `package.json` script entries (`lint:adr:0009` ... `lint:adr:0012`) mirroring 0002–0006 shape.
- **`tools/scripts/lint-*.mjs`** (existing pattern, ~15 files) — Establishes the convention for `lint:no-req-placeholders` (D-19/D-20).
- **`scripts/verify-phase-*.mjs`** (existing pattern) — Per-phase verify scripts; precedent for any Phase 7 verify hook (planner may or may not need one).
- **`traceable-reqs.toml` `[scan].roots`** — Canonical roots list for the placeholder-sweep regex target (D-20).

### Established Patterns

- **`package.json` lint:* convention**: each lint script is its own line + `tools/scripts/lint-*.mjs`. New entries follow this pattern (`lint:no-req-placeholders`, `lint:atlas-hash`, `preflight`).
- **`tsx tooling/<script>.ts` invocation**: existing pattern for TypeScript tooling without compilation. Used by `no-inline-origin.ts`. Other tooling under `tools/scripts/` uses plain `.mjs` — both are acceptable per CONVENTIONS.md.
- **ADR file pattern**: `docs/adr/<NNNN>-<kebab-title>.md`, four-digit zero-padded. New ADRs continue the 0009 sequence.
- **Operator-trust over programmatic check (06.7 carry-over)**: where automated detection would be brittle/cross-platform-fragile (e.g. LDtk version detection on Windows), prefer file-based assertion + operator discipline. Matches D-12.
- **`[<impl>->REQ-XX-NN]` tags in `.ts`/`.mjs` comments**: contract from CLAUDE.md "Tagging contract". All new Phase 7 scripts add tags.

### Integration Points

- `package.json` §scripts — additions: `preflight`, `lint:no-req-placeholders`, `lint:atlas-hash` (stub), `lint:adr:0009` through `lint:adr:0012`.
- `docs/deploy/LOCAL-DEPLOY.md` — new `## Preflight gate (MANDATORY)` section above the existing deploy steps.
- `docs/adr/` — 4 new ADR files (0009 gridSize, 0010 tilesetSourceHash, 0011 BNCentral chunking, 0012 LDtk version pin).
- `tools/ldtk-version.txt` — new single-line file (D-12).
- `tools/scripts/lint-no-req-placeholders.mjs` — new ~10-line script (D-20).
- `tools/scripts/lint-atlas-hash.mjs` — new stub script (D-15 step 6); no-op when `maps/` is empty, activates in Phase 8.
- `tools/scripts/check-conversion-regression.mjs` (or equivalent) — Phase 7 ships a **stub** logging `"skipped: pending Phase 9"` and exiting 0 (D-15 step 7).
- `maps/_smoke/synth-8000x6400.ldtk` — new (D-24); first content under the new top-level `maps/` dir.
- `.planning/phases/07-workflow-smoke-convention-locks/SMOKE-NOTES.md` — new (D-24); operator screenshot + subjective notes.
- `traceable-reqs.toml` — add 18 v1.1 entries (MAP-01..16 + HYG-01..02). Initial `required_stages` = `["doc"]` for all per CLAUDE.md "Adding or modifying requirements"; Phase 7 itself is doc-bearing for MAP-02 (the four ADRs) + impl-bearing for MAP-15, HYG-01, HYG-02. Tighten `required_stages` per-req at plan time.

</code_context>

<specifics>
## Specific Ideas

- **gridSize=4 unified** is the operator's explicit choice over the research-default 4/1 split. Rationale: single mental grid; entity 4 px snap acceptable since legacy BNO entities are tile-aligned anyway. Document this divergence prominently in ADR 0009 so future readers understand the choice was deliberate, not an oversight.
- **3×3 BNCentral chunking** is the operator's explicit choice over the research-default 4×4 or 2×2. Rationale: odd-numbered grid keeps the central hub area in a single chunk — the most important area becomes the easiest to author, not the hardest. Document in ADR 0011 with reference to BNCentral's hub-centric layout in `extracted/client-5-8/rooms/0058-BNCentral/`.
- **Phase 7 ships stubs for Phase 8/9 work**: `lint-atlas-hash.mjs` is a no-op until `maps/` has real content; `check-conversion-regression.mjs` (or equivalent) logs "skipped: pending Phase 9". Stubs are deliberate — leaves the preflight chain wired so future phases swap stub bodies, not chain entries.
- **No new package** in Phase 7. `packages/map-loader` is Phase 8 work. All Phase 7 code lives under `tools/scripts/`, `tooling/`, `docs/adr/`, `docs/deploy/`, and a single `maps/_smoke/` directory.

</specifics>

<deferred>
## Deferred Ideas

- **Concrete BNCentral conversion** (full 9-chunk authoring + import) — out of v1.1 per ROADMAP "Carried out of v1.1 scope". Phase 7 locks the chunk-grid convention only.
- **`__pivot` discard rule in `no-inline-origin.ts`** — belongs in Phase 8 alongside `LdtkLoader.ts` implementation, where the temptation to read `__pivot` actually exists.
- **CI restoration / Playwright smoke split** — todo `2026-05-16-split-playwright-smoke-into-3rd-workflow-job.md` (score 0.9). Predates operator-local `flyctl deploy` doctrine. Carried out of v1.1 per STATE.md; revisit when GH Actions storage available.
- **Hard latency budget for LDtk operations** (vs subjective gate) — v1.2+ if operator decides telemetry is worth tooling investment. Phase 7 stays qualitative per D-23.
- **Programmatic LDtk-install version introspection** — out of scope per D-12 (operator-trust posture). v1.2+ if LDtk ships a stable version-query CLI/registry surface on Windows.
- **Lag-event log / Windows ProcessExplorer integration** — rejected as overkill for v1.1 smoke gate; not deferred to a specific later phase.

### Reviewed Todos (not folded)

- `2026-05-16-split-playwright-smoke-into-3rd-workflow-job.md` — see "CI restoration" above. Reviewed score 0.9; deferred because CI is out of scope this milestone.
- `2026-05-17-phase-7-full-parity-backlog.md` — name collision only. This todo is the **relegated v1.0-era "Phase 7 Full Parity"**, NOT the new v1.1 Phase 7 "Workflow Smoke + Convention Locks". Separate work; relegated to v1.2+ per STATE.md.

</deferred>

---

*Phase: 7-Workflow Smoke + Convention Locks*
*Context gathered: 2026-05-19*
