# Phase 7: Workflow Smoke + Convention Locks — Research

**Researched:** 2026-05-19
**Domain:** Convention-locking ADRs + substitute-for-CI preflight tooling + hygiene drift-locks for the v1.1 LDtk-authoring milestone (no `.ldtk` runtime code lands in this phase; that is Phase 8+).
**Confidence:** HIGH (most claims `[CITED]` from in-repo files; LDtk schema `[CITED: ldtk.io MINIMAL_JSON_SCHEMA.json]`; one `[ASSUMED]` claim flagged for ADR 0010 — see Open Questions Q1).

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

### Locked Decisions

**gridSize ADR (ADR 0009)**
- **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 (ADR 0010)**
- **D-05:** Hash algorithm = **sha256** (hex string, 64 chars). Node `crypto` built-in; zero new dependencies.
- **D-06:** Placement = per-tileset custom field on each `TilesetDefinition` inside `.ldtk` (CONTEXT.md claims "LDtk natively supports `F_String` custom fields on tileset defs" — **see Open Question Q1, this is not directly true in the LDtk 1.5.3 schema; planner must select an alternative carrier and document in ADR 0010**).
- **D-07:** Drift behavior = **HARD throw + refuse to load**. Loader-init asserts each tileset's hash matches the sha256 of the referenced PNG. Matches Ed25519-sig-fail posture.
- **D-08:** ADR file: `docs/adr/0010-ldtk-tileset-source-hash.md`.

**BNCentral Chunk Grid ADR (ADR 0011)**
- **D-09:** Chunk grid = **3×3 GridVania** (9 chunks total). Odd dimension so the **center chunk** owns the BNCentral hub area uninterrupted.
- **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.
- **D-11:** ADR file: `docs/adr/0011-bncentral-gridvania-chunking.md`. Concrete BNCentral conversion is **out of v1.1 scope** — Phase 7 only locks the chunk-grid convention so v1.2 inherits a stable target.

**LDtk Version Pin ADR (ADR 0012)**
- **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.
- **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`). First failure stops; operator iterates one check at a time.
- **D-15:** 7-step composition order (each gates the next):
  1. LDtk version pin assert — read `tools/ldtk-version.txt`, echo to stdout, file existence + non-empty check
  2. `pnpm lint:room-layout` — existing
  3. `tsx tooling/no-inline-origin.ts` — existing HYG-01 D-63 gate
  4. `pnpm trace:check` — existing
  5. `pnpm lint:no-req-placeholders` — **NEW** (HYG-02 drift-lock)
  6. Atlas-hash assertion — **NEW** lightweight stub; no-op when `maps/` is empty, activates in Phase 8
  7. Conversion-regression check — Phase 9 owns real impl; Phase 7 stub logs `"skipped: pending Phase 9"` and exits 0
- **D-16:** Documented as MANDATORY in `docs/deploy/LOCAL-DEPLOY.md` (section heading: `## Preflight gate (MANDATORY)`).
- **D-17:** `package.json` entry: `"preflight": "<step1> && <step2> && ... && <step7>"`. No nested scripts file.

**HYG-01 — D-63 Audit**
- **D-18:** **Leave `tooling/no-inline-origin.ts` scope unchanged** in Phase 7 (current scope = `apps/client/src/render/`). Phase 7 work = wire into preflight step 3. The `__pivot` discard rule belongs in Phase 8 alongside `LdtkLoader.ts` implementation.

**HYG-02 — Placeholder Drift-Lock**
- **D-19:** Sweep result: "zero hits" claimed but **see Open Question Q5** — placeholder strings exist as meta-references in STATE.md, PROJECT.md, CONTEXT.md etc. (files that document the regex). Lint must exclude these files OR exclude markdown code-spans matching `` `REQ-…-XX` ``.
- **D-20:** Convert from one-shot sweep → **drift-lock script** `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/`. Wired into preflight step 5.
- **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 = **3×3 GridVania matching the BNCentral chunk grid exactly** (same dims per D-10). BORDERED floor tiles only — no entities, no auto-tile rules, no animated tiles.
- **D-23:** Pass gate = **subjective operator green-light + screenshot**. Boolean PASS/FAIL. No stopwatch budget, no automated UI-freeze detection.
- **D-24:** Artifacts: `.ldtk` source → `maps/_smoke/synth-8000x6400.ldtk` (establishes `maps/` top-level dir); 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, D-09/D-10 re-opens.

### 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; planner adjusts if 0009 already claimed.
- LOCAL-DEPLOY.md section ordering for the new "Preflight gate (MANDATORY)" block.

### Deferred Ideas (OUT OF SCOPE)

- **Concrete BNCentral conversion** (full 9-chunk authoring + import) — out of v1.1; Phase 7 locks convention only.
- **`__pivot` discard rule in `no-inline-origin.ts`** — belongs in Phase 8 alongside `LdtkLoader.ts` implementation.
- **CI restoration / Playwright smoke split** — carried out of v1.1 per STATE.md.
- **Hard latency budget for LDtk operations** (vs subjective gate) — v1.2+.
- **Programmatic LDtk-install version introspection** — out per D-12.
- **Lag-event log / Windows ProcessExplorer integration** — rejected as overkill.

</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|------------------|
| MAP-02 | Convention ADRs locked before any `.ldtk` file authored (gridSize, BNCentral chunk grid, tilesetSourceHash, LDtk version pin) | §"ADR Compliance" + §"LDtk Schema Specifics" + §"BNCentral Chunking" |
| MAP-15 | `pnpm preflight` script substituting missing CI; runs LDtk-version check, atlas-hash assertion, conversion-regression test, `pnpm trace:check`; non-zero on failure; documented as gate before `flyctl deploy` | §"Preflight Wiring" + §"Atlas-Hash Stub" + §"Conversion-Regression Stub" |
| HYG-01 | `tooling/no-inline-origin.ts` D-63 audit script wired into `pnpm preflight`. Catches Pitfall #1 (D-63 bypass) mechanically | §"HYG-01 Wiring" |
| HYG-02 | `pnpm trace:check` placeholder sweep — `REQ-SRV-XX` / `REQ-X` placeholders cleaned and drift-locked | §"HYG-02 Drift-Lock" |

</phase_requirements>

## Summary

Phase 7 is a **convention-locking + tooling phase**; it ships **no runtime code**. Four ADRs (0009 gridSize, 0010 tilesetSourceHash, 0011 BNCentral chunking, 0012 LDtk version pin), one preflight script (7-step `&&` chain), two hygiene gates (HYG-01 wiring, HYG-02 drift-lock), and one operator-machine smoke test. The output is a set of files under `docs/adr/`, `docs/deploy/`, `tools/`, `tools/scripts/`, `maps/_smoke/`, and a `traceable-reqs.toml` entries-table extension. No new TS packages, no new npm dependencies, no Phaser changes, no `.ldtk` runtime parser.

The research surfaces ONE non-trivial discrepancy between CONTEXT.md and the LDtk 1.5.3 schema (D-06's "F_String custom fields on tileset defs" claim — see §Open Question Q1) that the planner must resolve at plan time before ADR 0010 can be authored. Otherwise every CONTEXT.md decision lands cleanly against current repo state.

**Primary recommendation:** Decompose into **5 parallelizable plans + 1 serial closer**:
1. ADR 0009 (gridSize) — parallel
2. ADR 0010 (tilesetSourceHash) — parallel; planner must resolve Q1 first
3. ADR 0011 (BNCentral chunking) — parallel
4. ADR 0012 (LDtk version pin) + `tools/ldtk-version.txt` file — parallel
5. Preflight wiring (`lint-no-req-placeholders.mjs` + `lint-atlas-hash.mjs` stub + conversion-regression stub + `package.json` script + LOCAL-DEPLOY.md section) — parallel
6. **Serial closer:** `traceable-reqs.toml` already contains all 18 v1.1 entries (verified at line 326-417 of the manifest); operator-machine smoke test depends on ADRs 0009 + 0011 being authored. SMOKE-NOTES.md + `maps/_smoke/synth-8000x6400.ldtk` get created at the end.

## Architectural Responsibility Map

Phase 7 ships convention + tooling artifacts, not runtime code. Tier mapping reflects where the artifacts live and what they govern.

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Four ADRs (0009–0012) | Documentation (`docs/adr/`) | — | Pure prose; no code at this tier in Phase 7 |
| `pnpm preflight` orchestrator | Build/CI tooling (`package.json`) | Operator-machine | Substitutes for missing GH Actions CI |
| `lint-no-req-placeholders.mjs` | Build/CI tooling (`tools/scripts/`) | Documentation (scans `.planning/`, `docs/`) | Static-grep linter; reads markdown/code, writes nothing |
| `lint-atlas-hash.mjs` (stub) | Build/CI tooling | Future: Asset pipeline (`apps/client/public/atlas-mvp.png`) | Phase 7 = no-op early-return when `maps/` empty; activates in Phase 8 |
| `check-conversion-regression.mjs` (stub) | Build/CI tooling | Future: Converter (`tools/room-converter/`) | Phase 7 = log "skipped: pending Phase 9", exit 0 |
| `tools/ldtk-version.txt` | Build/CI tooling | Operator-machine (operator-trust file) | Single-line version pin; no programmatic LDtk introspection |
| `docs/deploy/LOCAL-DEPLOY.md` "Preflight gate (MANDATORY)" section | Documentation / Operator runbook | — | Updates existing operator-local deploy doctrine |
| `tooling/no-inline-origin.ts` wiring | Build/CI tooling | Client render (`apps/client/src/render/`) | Existing 87-line script unchanged; only preflight reference added |
| `maps/_smoke/synth-8000x6400.ldtk` + SMOKE-NOTES.md | Operator-authored content + Documentation | — | Throwaway smoke artifacts; live under `maps/_smoke/` |
| 18 v1.1 entries in `traceable-reqs.toml` | Requirements traceability | — | Already present per manifest read (lines 326-417); no Phase 7 action needed unless planner verifies and removes stale ones |

## Standard Stack

Phase 7 introduces **zero new runtime libraries**. All scripting uses Node built-ins.

### Core

| Tool | Version | Purpose | Why Standard |
|------|---------|---------|--------------|
| Node `crypto.createHash('sha256')` | Built-in (Node 22) | Atlas PNG hash for tilesetSourceHash assertion (stub in Phase 7) | Zero new deps; matches Ed25519 in `tools/room-converter/src/sign.ts` (CITED: ADR 0004) |
| Node `node:fs` | Built-in | Read `tools/ldtk-version.txt`, scan `maps/`, walk `traceable-reqs.toml [scan].roots` | Standard for `tools/scripts/lint-*.mjs` pattern (CITED: `tools/scripts/lint-no-clipboard-rce.mjs:13`) |
| Node `child_process.execSync('git grep')` | Built-in | Pattern matching (used by `tooling/no-inline-origin.ts` already) | Existing pattern (CITED: `tooling/no-inline-origin.ts:34`) |
| `traceable-reqs` CLI | Already installed | `pnpm trace:check` (preflight step 4) | Already wired (CITED: `package.json:67`) |
| `tsx` | 4.21.0 (already pinned) | `tsx tooling/no-inline-origin.ts` invocation | Already in devDependencies (CITED: `package.json:85`) |

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Plain `node tools/scripts/*.mjs` | `tsx tools/scripts/*.ts` | All existing `tools/scripts/lint-*` use `.mjs` — match convention (CITED: `tools/scripts/` directory listing — 12 `.mjs` files, 0 `.ts` files) |
| `&&` chain in `package.json` | npm-run-all `run-s` | New dependency; `&&` works cross-shell on Windows/Mac/Linux; matches existing chain style (CITED: `package.json:27` `lint:adrs` chains 6 commands with `&&`) |
| `crypto.subtle.digest('SHA-256', …)` (WebCrypto) | `crypto.createHash('sha256')` (legacy) | Both work; existing room-key.ts (ADR 0004) uses createSign — Node createHash is the consistent choice |
| Per-step `.mjs` files | Inline JS string in package.json | Inline lets future maintainer parse the chain at a glance per D-17 ("keep the chain visible in package.json so drift requires an explicit edit") — keep package.json explicit |

**Installation:**

```bash
# No npm installs in Phase 7. All tools are Node built-ins or already pinned.
```

**Version verification:** Not applicable — Phase 7 adds no new packages. The locked v1.1 stack additions (`ajv@8.17.1`, `chokidar@4`, `quicktype@23.2.6`) belong to Phase 8 per STACK.md and are not Phase 7 concerns.

## Package Legitimacy Audit

**Not applicable** — Phase 7 installs zero external packages. All tooling uses Node 22 built-ins (`crypto`, `fs`, `child_process`) and a pre-existing dev-dep (`tsx@4.21.0`). slopcheck gate is satisfied vacuously.

## Architecture Patterns

### System Architecture Diagram

```
                        ┌──────────────────────────────┐
                        │  Operator runs `pnpm preflight`
                        │  (BEFORE every flyctl deploy)
                        └──────────────────────────────┘
                                      │
                                      ▼
       ┌───────────────────────────────────────────────────────┐
       │ Step 1: cat tools/ldtk-version.txt → echo to stdout   │
       │         (fails if file missing or empty)              │
       └───────────────────────────────────────────────────────┘
                                      │ (exit 0)
                                      ▼
       ┌───────────────────────────────────────────────────────┐
       │ Step 2: pnpm lint:room-layout                         │
       │         (existing; gates layoutSchema union shapes)   │
       └───────────────────────────────────────────────────────┘
                                      │ (exit 0)
                                      ▼
       ┌───────────────────────────────────────────────────────┐
       │ Step 3: tsx tooling/no-inline-origin.ts               │
       │         (existing; D-63 HARD gate, scope=             │
       │          apps/client/src/render/)                     │
       └───────────────────────────────────────────────────────┘
                                      │ (exit 0)
                                      ▼
       ┌───────────────────────────────────────────────────────┐
       │ Step 4: pnpm trace:check                              │
       │         (existing; reqs-tag coverage)                 │
       └───────────────────────────────────────────────────────┘
                                      │ (exit 0)
                                      ▼
       ┌───────────────────────────────────────────────────────┐
       │ Step 5: pnpm lint:no-req-placeholders                 │
       │         (NEW; HYG-02 drift-lock)                      │
       └───────────────────────────────────────────────────────┘
                                      │ (exit 0)
                                      ▼
       ┌───────────────────────────────────────────────────────┐
       │ Step 6: pnpm lint:atlas-hash                          │
       │         (NEW stub; no-op when maps/ empty; activates  │
       │          in Phase 8 once TestBed_001.ldtk lands)      │
       └───────────────────────────────────────────────────────┘
                                      │ (exit 0)
                                      ▼
       ┌───────────────────────────────────────────────────────┐
       │ Step 7: node tools/scripts/check-conversion-regression│
       │         (NEW stub; logs "skipped: pending Phase 9";   │
       │          Phase 9 swaps stub body)                     │
       └───────────────────────────────────────────────────────┘
                                      │ (exit 0)
                                      ▼
                        ┌──────────────────────────────┐
                        │  Operator runs `flyctl deploy`
                        └──────────────────────────────┘
```

**First failure aborts; chain exit code propagates to `pnpm preflight` exit code; operator iterates one check at a time.**

### Recommended File Layout (Phase 7 deltas only)

```
docs/
├── adr/
│   ├── 0009-ldtk-gridsize-convention.md             ← NEW
│   ├── 0010-ldtk-tileset-source-hash.md             ← NEW
│   ├── 0011-bncentral-gridvania-chunking.md         ← NEW
│   └── 0012-ldtk-version-pin.md                     ← NEW
└── deploy/
    └── LOCAL-DEPLOY.md                              ← MODIFIED (Preflight gate section)

tools/
├── ldtk-version.txt                                 ← NEW (single line: "1.5.3")
└── scripts/
    ├── lint-no-req-placeholders.mjs                 ← NEW (~30 lines)
    ├── lint-atlas-hash.mjs                          ← NEW stub (~20 lines)
    └── check-conversion-regression.mjs              ← NEW stub (~10 lines)

maps/                                                ← NEW top-level dir
└── _smoke/
    └── synth-8000x6400.ldtk                         ← NEW (operator-authored)

.planning/phases/07-workflow-smoke-convention-locks/
└── SMOKE-NOTES.md                                   ← NEW (operator notes + screenshot)

package.json                                         ← MODIFIED (add 4 scripts + preflight chain)
traceable-reqs.toml                                  ← VERIFIED (18 v1.1 entries already present)
tooling/no-inline-origin.ts                         ← MODIFIED (add [impl->REQ-HYG-01] tag only)
```

### Pattern 1: ADR Lint Compliance

**What:** `tools/asset-catalog/scripts/lint-adr.mjs` (CITED: full file at `tools/asset-catalog/scripts/lint-adr.mjs:1-164`) enforces 4 required Michael Nygard sections on every ADR. For Phase 3+ non-engine ADRs (which includes 0009–0012), pass `--no-matrix` to skip the MX-* citation + Phaser 4 caveat + MATRIX.md reference checks.

**Minimum ADR template (satisfies `lint-adr.mjs --no-matrix`):**

```markdown
# ADR NNNN: <title>

**Date:** YYYY-MM-DD
**Phase:** 7 (Workflow Smoke + Convention Locks)

[doc->REQ-MAP-02]

## Status

**Accepted** — <re-evaluation gate>.

## Context

<what problem this ADR addresses; cite source-of-truth files>

## Decision

<the locked choice; concrete; copy-pasteable values>

## Consequences

### Positive
- <upside>

### Negative
- <downside>

### Neutral
- <orthogonal effect>

## Alternatives considered

- **<alt>** — Rejected: <reason>.

## References

- <in-repo source files cited>
```

**`lint-adr.mjs --no-matrix` enforced sections (CITED: `lint-adr.mjs:67-72`):**
- `## Status`
- `## Context`
- `## Decision`
- `## Consequences`

**Skipped in `--no-matrix` mode (CITED: `lint-adr.mjs:86-153`):**
- Phaser 4 caveat (regex `/[Pp]haser\s*4/`)
- ≥3 unique `MX-*` citations
- `MATRIX.md` mention in References

**`package.json` script additions for ADR lints (mirroring lines 22-27):**

```json
"lint:adr:0009": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0009-ldtk-gridsize-convention.md --no-matrix",
"lint:adr:0010": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0010-ldtk-tileset-source-hash.md --no-matrix",
"lint:adr:0011": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0011-bncentral-gridvania-chunking.md --no-matrix",
"lint:adr:0012": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0012-ldtk-version-pin.md --no-matrix",
```

And extend the existing `lint:adrs` chain (CITED: `package.json:27`) with `&& pnpm lint:adr:0009 && pnpm lint:adr:0010 && pnpm lint:adr:0011 && pnpm lint:adr:0012`.

### Pattern 2: Preflight Composition (literal package.json value)

**Recommended literal value for `package.json` `"preflight"` script:**

```json
"preflight": "node -e \"const v=require('fs').readFileSync('tools/ldtk-version.txt','utf8').trim();if(!v)throw new Error('ldtk-version.txt empty');console.log('LDtk pin:',v)\" && pnpm lint:room-layout && pnpm gate:no-inline-origin && pnpm trace:check && pnpm lint:no-req-placeholders && pnpm lint:atlas-hash && node tools/scripts/check-conversion-regression.mjs"
```

Notes:
- `pnpm gate:no-inline-origin` (CITED: `package.json:81`) is the existing alias for `pnpm tsx tooling/no-inline-origin.ts`; reuse it.
- Inline `node -e` for step 1 (LDtk version pin) avoids creating a separate `.mjs` file for a 3-line operation; matches inline-script pattern at `package.json:34-35` (`db:emit-check`, `lint:schema-sync`).
- `&&` chain works cross-shell on cmd.exe (Windows), PowerShell, bash, zsh — verified pattern used by `lint:adrs` (`package.json:27`) and `extract:all` (`package.json:8`).
- Each step exits non-zero on failure; chain stops at first failure; final exit code is the failing step's exit code.

### Pattern 3: HYG-02 Drift-Lock Linter

**What it does:** Greps for `REQ-…-XX` placeholder tokens across the `traceable-reqs.toml [scan].roots` directories (CITED: `traceable-reqs.toml:22-31` lists `docs`, `decomp/wiki`, `tools`, `scripts`, `.planning`, `packages`, `apps`); fails with exit 1 if any hits found (excluding allowlist).

**Recommended regex:**

```javascript
// tools/scripts/lint-no-req-placeholders.mjs
const PLACEHOLDER_RE = /REQ-(?:SRV|CLI|MAP|HYG|DEP|EXT|CDOC|SDOC|AST|PAR|[A-Z]+)-XX\b|REQ-X(?![-A-Z0-9])/g;
```

**Critical: file allowlist (planner must include)** — D-19 claims "zero hits 2026-05-19" but a literal grep across the repo currently returns matches in these files because they DOCUMENT the placeholder regex itself (meta-references, not debt):

| File | Why excluded |
|------|--------------|
| `.planning/REQUIREMENTS.md` | Quotes the regex in the HYG-02 description (line 76) |
| `.planning/STATE.md` | Quotes the regex describing what's pulled into v1.1 scope (line 58) |
| `.planning/PROJECT.md` | Lists the HYG-02 backlog item with the regex inline (line 74) |
| `.planning/ROADMAP.md` | Phase 7 Success Criterion 4 references the regex |
| `.planning/research/v1.1/SUMMARY.md` | Documents the HYG-02 deferred item |
| `.planning/research/v1.1/PITFALLS.md` | "REQ-MAP-* IDs" meta-reference (line 369) |
| `.planning/research/v1.1/STACK.md` | "REQ-MAP-XX entries" meta-reference (line 201) |
| `.planning/phases/07-workflow-smoke-convention-locks/07-CONTEXT.md` | D-19/D-20 quote the regex verbatim |
| `.planning/phases/07-workflow-smoke-convention-locks/07-DISCUSSION-LOG.md` | Discussion of the regex |
| `.planning/phases/07-workflow-smoke-convention-locks/07-RESEARCH.md` | This file — also discusses the regex |
| `.planning/milestones/v1.0-ROADMAP.md` | Archived v1.0 roadmap mentions |
| `.planning/milestones/v1.0-phases/**` | Archived v1.0 SUMMARY/VERIFIER/VERIFICATION files quoting the regex |
| `tools/scripts/lint-no-req-placeholders.mjs` | The linter itself (contains the regex) |
| `traceable-reqs.toml` | Contains the regex in header comments |

**Recommended allowlist strategy** (planner picks one):
- **(a) Path-based allowlist** — exclude `.planning/milestones/`, `.planning/phases/07-*/`, `.planning/research/v1.1/`, `.planning/REQUIREMENTS.md`, `.planning/STATE.md`, `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `tools/scripts/lint-no-req-placeholders.mjs`, `traceable-reqs.toml`. Simple but brittle (forgets future docs that legitimately quote the regex).
- **(b) Inline annotation** — only flag lines that do NOT contain a `<!-- lint-no-req-placeholders: allow -->` comment or are inside markdown code-fences. More flexible but requires touching legitimate files.
- **(c) Code-fence/code-span aware** — strip backtick spans + fenced blocks before matching. Cleanest for markdown but adds parsing complexity.

**Recommendation:** Start with **(a)** — path-based allowlist documented in script header. Operator can add files to the allowlist as needed. ≤30 lines total including allowlist array. If false-positive churn becomes painful, evolve to **(c)** later.

**Script skeleton:**

```javascript
#!/usr/bin/env node
// tools/scripts/lint-no-req-placeholders.mjs
// [impl->REQ-HYG-02]
//
// HYG-02 drift-lock — fail if any REQ-...-XX placeholder string surfaces in
// docs/, .planning/, packages/, apps/, tools/, scripts/. Allowlist excludes
// meta-references (files that document the regex itself).
import { execSync } from 'node:child_process';

const PATTERN = 'REQ-(SRV|CLI|MAP|HYG|DEP|EXT|CDOC|SDOC|AST|PAR|[A-Z]+)-XX|REQ-X[^-A-Z0-9]';
const ROOTS = ['docs', 'decomp/wiki', 'tools', 'scripts', '.planning', 'packages', 'apps'];
const ALLOWLIST = [
  '.planning/REQUIREMENTS.md',
  '.planning/STATE.md',
  '.planning/PROJECT.md',
  '.planning/ROADMAP.md',
  '.planning/research/v1.1/',
  '.planning/phases/07-workflow-smoke-convention-locks/',
  '.planning/milestones/',
  'tools/scripts/lint-no-req-placeholders.mjs',
  'traceable-reqs.toml',
];

let out = '';
try {
  out = execSync(`git grep -nE "${PATTERN}" -- ${ROOTS.join(' ')}`, { encoding: 'utf8' });
} catch (e) {
  if (e.status === 1) { console.log('lint-no-req-placeholders: OK'); process.exit(0); }
  throw e;
}

const violations = out
  .split('\n')
  .filter(Boolean)
  .filter((line) => !ALLOWLIST.some((p) => line.startsWith(p)));

if (violations.length === 0) {
  console.log('lint-no-req-placeholders: OK (allowlisted meta-references filtered)');
  process.exit(0);
}

process.stderr.write('lint-no-req-placeholders: FAIL\n' + violations.join('\n') + '\n');
process.exit(1);
```

### Pattern 4: Atlas-Hash Stub (sha256 of PNG)

**Phase 7 behavior:** no-op early-return when `maps/` doesn't exist or is empty (other than the `_smoke/` subdir).

**Phase 8 activation:** once `maps/TestBed_001.ldtk` lands, the stub body grows to scan all `.ldtk` files under `maps/`, extract whatever carrier ADR 0010 selects for `tilesetSourceHash` (see Open Question Q1), and compare each declared hash to `crypto.createHash('sha256').update(readFileSync('apps/client/public/atlas-mvp.png')).digest('hex')`.

**Phase 7 stub skeleton (~20 lines):**

```javascript
#!/usr/bin/env node
// tools/scripts/lint-atlas-hash.mjs
// [impl->REQ-MAP-02] (placeholder for Phase 8 [impl->REQ-MAP-01])
//
// Stub: assert that no .ldtk file declares a tilesetSourceHash that mismatches
// the live sha256(apps/client/public/atlas-mvp.png). When maps/ is empty,
// early-return cleanly. Activates in Phase 8 once TestBed_001.ldtk lands.
import { readdirSync, existsSync, readFileSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';

const MAPS_DIR = 'maps';
if (!existsSync(MAPS_DIR)) {
  console.log('lint-atlas-hash: OK (maps/ does not exist yet — Phase 7 baseline)');
  process.exit(0);
}

// Recursive .ldtk file discovery
function findLdtk(dir, out = []) {
  for (const f of readdirSync(dir)) {
    const p = join(dir, f);
    if (statSync(p).isDirectory()) findLdtk(p, out);
    else if (p.endsWith('.ldtk')) out.push(p);
  }
  return out;
}

const ldtkFiles = findLdtk(MAPS_DIR).filter((p) => !p.includes('_smoke')); // skip throwaway smoke files
if (ldtkFiles.length === 0) {
  console.log('lint-atlas-hash: OK (no production .ldtk files yet — Phase 8 will activate)');
  process.exit(0);
}

// Phase 8 swaps the body below:
// const atlasHash = createHash('sha256').update(readFileSync('apps/client/public/atlas-mvp.png')).digest('hex');
// for each .ldtk file: parse, extract tilesetSourceHash carrier, compare, throw on mismatch.
console.log('lint-atlas-hash: Phase 8 logic not yet wired —', ldtkFiles.length, 'file(s) found, deferred');
process.exit(0);
```

**`apps/client/public/atlas-mvp.png` location** (CITED: ARCHITECTURE.md line 32 — `apps/client/public/atlas-mvp.{json,png}` runtime-loaded). The full-path deploy memory `fly-fullpath-deploy-data-symlink-gap.md` confirms this is the deploy target path.

### Pattern 5: Conversion-Regression Stub

**Phase 7 behavior:** log skip message, exit 0.

```javascript
#!/usr/bin/env node
// tools/scripts/check-conversion-regression.mjs
// [impl->REQ-MAP-02] (placeholder for Phase 9 [impl->REQ-MAP-09])
//
// Stub: in Phase 9 this will re-run tools/room-converter ldtk-import on a
// fixture .ldtk and assert byte-stable output (deterministic round-trip).
// Phase 7 ships the wired slot so Phase 9 only swaps the body.
console.log('check-conversion-regression: skipped (pending Phase 9 ldtk-import subcommand)');
process.exit(0);
```

### Anti-Patterns to Avoid

- **Anti-pattern: "Phase 7 ships LDtk loader code."** Phase 7 ships ZERO runtime code. The loader is Phase 8 (per ROADMAP.md "Phase 8: Map-Loader Package + Phaser LdtkLoader + TestBed_001"). Don't even create `packages/map-loader/` in Phase 7 — that's a Phase 8 deliverable.
- **Anti-pattern: "Programmatic LDtk-install version detection."** Out per D-12 (operator-trust posture). LDtk is a desktop GUI app with no stable CLI/registry version-query surface on Windows; chasing this is yak-shaving.
- **Anti-pattern: "Eager `lint-atlas-hash.mjs` implementation."** Phase 7 stub must be a NO-OP when `maps/` is empty. Writing the full hash-extraction logic now means writing it against a carrier (Open Question Q1) that may not yet be locked. Defer to Phase 8.
- **Anti-pattern: "Broadening `tooling/no-inline-origin.ts` scope."** D-18 LOCKS the current scope (`apps/client/src/render/`) unchanged. The `__pivot` discard rule is Phase 8 work — added when `LdtkLoader.ts` is authored. Phase 7 ONLY wires the existing 87-line script into preflight step 3.
- **Anti-pattern: "Hand-rolling sha256 or Ed25519 in a new script."** Reuse Node `crypto` built-ins; never introduce a third crypto library. ADR 0004 documents the existing Ed25519 contract; ADR 0010 inherits the same posture.
- **Anti-pattern: "Adding npm packages to package.json for Phase 7."** Zero new packages this phase.

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| ADR section validation | Custom YAML parser or per-ADR check | `tools/asset-catalog/scripts/lint-adr.mjs --no-matrix` | Already exists, hardened (CITED: file path `tools/asset-catalog/scripts/lint-adr.mjs:1-164`); proven by ADRs 0002–0006 |
| sha256 of a PNG | Custom Buffer-walk hash | `crypto.createHash('sha256').update(buf).digest('hex')` | Node built-in; constant-time enough for non-secret comparison |
| Recursive file-walk | Custom DFS | `readdirSync` + recursion (pattern at `tools/scripts/lint-no-clipboard-rce.mjs:36-44`) | Established repo convention |
| Comment stripping in linters | Custom tokenizer | `stripComments` pattern at `tools/scripts/lint-no-clipboard-rce.mjs:28-34` | Cheap, established |
| Cross-shell command chains | npm-run-all or shell-specific syntax | `&&` chain in `package.json` | Works on cmd.exe + PowerShell + bash; existing pattern (`package.json:8, 27`) |
| ADR numbering | Random/timestamp-based | Continue zero-padded sequence after `0008-canvas-base-resolution.md` (next: `0009`) | Established convention (CITED: `docs/adr/` listing — 0001 through 0008 zero-padded, kebab-case titles) |
| Requirement traceability tags | Custom regex linter | `traceable-reqs` CLI already installed | `pnpm trace:check` exists (CITED: `package.json:67`); 18 v1.1 entries already in `traceable-reqs.toml:326-417` |

**Key insight:** Phase 7 is mostly *wiring existing tooling*, not building new tooling. The only new code is three small scripts (~50 LOC total) that bridge gaps in the existing chain. Don't over-engineer this phase.

## Runtime State Inventory

Phase 7 is a greenfield phase for the four ADRs + preflight tooling + smoke artifact. It does NOT rename, refactor, or migrate anything. The "Runtime State Inventory" section is therefore largely vacuous, but I record each category explicitly to confirm no hidden state.

| Category | Items Found | Action Required |
|----------|-------------|------------------|
| Stored data | None — Phase 7 ships no database changes, no SQLite migrations, no Mem0/Chroma/etc. interactions | None |
| Live service config | None — Phase 7 touches no Fly machine config, no Datadog tags, no Tailscale, no n8n, no Cloudflare Tunnel | None |
| OS-registered state | None — Phase 7 ships no scheduled tasks, no pm2 saves, no systemd unit changes | None |
| Secrets/env vars | None new — preflight reads `tools/ldtk-version.txt` (plain file, not a secret). The `keys/rebno-room-signing.ed25519` private key is NOT touched in Phase 7 (only referenced by Phase 8+ when LDtk-derived rooms get signed) | None |
| Build artifacts | None — Phase 7 does not regenerate `apps/client/public/atlas-mvp.png`, does not regenerate `pipeline-manifest.json`, does not regenerate Drizzle migrations | None |

**Inventory complete — nothing found in any category.** This is expected for a convention-locking phase.

## Common Pitfalls

### Pitfall 1: TilesetDef `customFields` confusion (D-06 vs schema reality)

**What goes wrong:** D-06 in CONTEXT.md states "LDtk natively supports `F_String` custom fields on tileset defs". The LDtk 1.5.3 schema (CITED: `https://ldtk.io/files/MINIMAL_JSON_SCHEMA.json`) does NOT expose a per-TilesetDef custom-field surface. The actual available fields on a `TilesetDefJson` are: `identifier`, `uid`, `pxWid`, `pxHei`, `__cWid`, `__cHei`, `padding`, `spacing`, `tileGridSize`, `tags` (array of user-defined strings), `tagsSourceEnumUid`, `enumTags`, and `customData` (per-tile metadata, NOT per-tileset). There is no `customFields` array at the TilesetDef level analogous to what LayerDef and EntityDef have.

**Why it happens:** Confusion with `EntityDefJson.customFields` (which IS where `F_String` custom fields live). Operator and prior research may have assumed symmetry across all definition types.

**How to avoid:** ADR 0010 author must select one of these carriers and document the choice explicitly:
- **Option A — `tags` array on TilesetDef.** Push a string like `"tilesetSourceHash=<sha256-hex>"` into the user-defined `tags` array. Schema-legal; no LDtk editor support for strict parsing (operator could accidentally edit). Loader parses tag with `String.prototype.startsWith('tilesetSourceHash=')`.
- **Option B — Sidecar JSON.** Maintain `maps/<world>.tilesetHashes.json` paired with each `.ldtk`. Decouples hash from LDtk editor entirely; survives any LDtk-side edit; loader reads sidecar at init.
- **Option C — `customData` on tile UID 0.** Hijack the per-tile customData of tile id 0 (the "first tile") with a special-string format like `__projectMeta:tilesetSourceHash=<hash>`. Schema-legal but semantically wrong; collides with real per-tile custom data.

**Recommendation for ADR 0010:** Option B (sidecar JSON). Reasons: (1) keeps `.ldtk` clean of REBNO-specific opaque fields, (2) makes the hash trivially scriptable in `lint-atlas-hash.mjs` (parse JSON, not LDtk), (3) avoids any chance of operator accidentally clearing the hash in the LDtk editor UI.

**Warning signs:** Plan task references "F_String custom field on TilesetDefinition" — that's the unverified D-06 claim. Plan should reference the carrier choice (A/B/C) by name.

### Pitfall 2: HYG-02 false-positive avalanche on first run

**What goes wrong:** Operator runs `pnpm lint:no-req-placeholders` and is greeted by 20+ "violations" — all of which are this research file, the CONTEXT.md, the STATE.md, etc. quoting the regex itself. Operator concludes "the lint is broken" and disables it, defeating the drift-lock.

**Why it happens:** D-19 claims "zero hits" but a literal grep returns 20+ hits because meta-references exist (see Pattern 3 allowlist table above).

**How to avoid:** Build the allowlist into the FIRST commit of `lint-no-req-placeholders.mjs`. Don't ship it without allowlist and then "fix later". Ship the script and the allowlist together. Document the allowlist intent in the script header so future maintainers understand why certain paths are excluded.

**Warning signs:** First `pnpm lint:no-req-placeholders` run after merging Phase 7 fails. If the failing files are all in the allowlist table, the allowlist itself wasn't shipped or didn't activate.

### Pitfall 3: `pnpm preflight` Windows shell quoting

**What goes wrong:** Inline `node -e "..."` in `package.json` with embedded single quotes fails on cmd.exe Windows because cmd.exe doesn't recognize single quotes as string delimiters. The chain breaks at step 1 silently or with cryptic "is not recognized" errors.

**Why it happens:** Different shells (cmd.exe, PowerShell, bash) have different quote semantics. JSON-escaped double quotes inside `node -e` arg work everywhere; nested single quotes don't.

**How to avoid:** Test the preflight chain on the operator's Windows machine BEFORE closing the plan. Use double-escaped JSON: `"node -e \"const v=require('fs')...\"`. Avoid raw single quotes inside `node -e`. Operator can sanity-check by running `pnpm preflight` from cmd.exe, PowerShell, AND Git Bash; all three must succeed.

**Alternative if Windows quoting becomes painful:** Promote step 1 to a dedicated `tools/scripts/check-ldtk-version.mjs` (~10 lines). Slight DRY hit (one more file), big portability win.

**Warning signs:** Preflight passes on macOS/Linux dev box but fails on Windows with quoting errors. Plan needs a `verify`-style smoke per-shell.

### Pitfall 4: Stubs never get swapped

**What goes wrong:** Phase 7 ships `lint-atlas-hash.mjs` and `check-conversion-regression.mjs` as no-op stubs. Phase 8/9 land, but the stubs stay as no-ops because the swap-in tasks are easy to forget. Result: `pnpm preflight` passes on broken builds for months.

**Why it happens:** "Already wired, will fix later" is a common deferral pattern; "later" never arrives unless explicitly tracked.

**How to avoid:** Add to each stub:
1. A leading `// TODO: REQ-MAP-XX activation in Phase N` comment with the specific REQ ID.
2. A console.log line that says "stub — pending Phase N" so EVERY preflight run prints a visible reminder. Make the reminder annoying enough that it cannot be ignored.
3. (Stretch — planner discretion) A `traceable-reqs.toml` `required_stages` entry for MAP-09 and MAP-15 that includes `unit` — the unit-test surface for these stubs lives in Phase 7's plan but the assertion-against-real-output lives in Phase 8/9.

**Warning signs:** Stub logs `"skipped"` repeatedly across multiple preflight runs without anyone noticing. Phase 8 plan does not reference Phase 7 stubs.

### Pitfall 5: ADR 0011 chunk-dim arithmetic drift

**What goes wrong:** D-10 specifies chunk dims `2668/2664/2668 × 2132/2136/2132`. Sums: cols `2668+2664+2668 = 8000` ✓; rows `2132+2136+2132 = 6400` ✓. Each dim divisible by 4 ✓ (`2668/4 = 667`, `2664/4 = 666`, `2132/4 = 533`, `2136/4 = 534`). However: when ADR 0011 is rewritten or copy-pasted, a typo (e.g. `2664` → `2644`) breaks the sum invariant silently.

**Why it happens:** Manual transcription of magic numbers across docs.

**How to avoid:** ADR 0011 includes the arithmetic check inline:
```
cols: 2668 + 2664 + 2668 = 8000 ✓ (matches BNCentral pxWid)
rows: 2132 + 2136 + 2132 = 6400 ✓ (matches BNCentral pxHei)
all dims % 4 == 0 ✓ (honors gridSize=4)
```
And a unit-test-style assertion in any future ADR-validator script. For Phase 7 this is text-only.

**Warning signs:** Operator authors `maps/_smoke/synth-8000x6400.ldtk` with chunk dims that sum to 8004 or 7996 (off-by-N), discovers the smoke world doesn't fit, blames LDtk.

### Pitfall 6: Smoke test conflates "tolerable" with "deliverable"

**What goes wrong:** Operator authors the 3×3 GridVania smoke world, finds it slightly laggy (e.g. 1.5s paint stroke on the center chunk), shrugs, marks PASS, ships ADR 0011. Phase 8+ builds on top, BNCentral conversion happens in v1.2, and the lag turns out to be unworkable.

**Why it happens:** D-23 explicitly chooses subjective gate over stopwatch. Subjective gates have low resolution.

**How to avoid:** SMOKE-NOTES.md template requires the operator to write a short qualitative descriptor per chunk (e.g. "center: instant", "NW: <1s noticeable but tolerable", "center while bordered IntGrid 80% filled: 2-3s — borderline"). Subjective phrasing per D-23 is fine, but multi-chunk + multi-state coverage forces the operator to think rather than skim.

**Warning signs:** SMOKE-NOTES.md says "looks fine" with no per-chunk detail.

## Code Examples

### Verified Pattern: ADR `[<doc>->REQ-X]` tag placement

(CITED: `docs/adr/0008-canvas-base-resolution.md:5`, `docs/adr/0004-room-hot-reload.md:3`, `docs/adr/0007-phase-6-protocol-amendments.md:7`)

```markdown
# ADR NNNN: <title>

**Date:** YYYY-MM-DD
**Phase:** N (<phase name>)

[doc->REQ-MAP-02]

## Status
...
```

The `[doc->REQ-MAP-02]` tag MUST appear on its own line (or comma-separated with siblings) between the title-block and `## Status`. The `traceable-reqs trace REQ-MAP-02` CLI scans for this exact syntax.

### Verified Pattern: lint-script header

(CITED: `tools/scripts/lint-no-clipboard-rce.mjs:1-11`, `tools/scripts/lint-room-layout.mjs:1-12`)

```javascript
#!/usr/bin/env node
// tools/scripts/lint-<name>.mjs
// [<impl>->REQ-XX-NN]
// Source: <where this lint requirement was decided>
//
// <what this script enforces>
//
// Usage: node tools/scripts/lint-<name>.mjs
// Exit:  0 clean, 1 violation.
```

### Verified Pattern: package.json `&&` chain (cross-shell portable)

(CITED: `package.json:8` `extract:all` chains 2 commands; `package.json:27` `lint:adrs` chains 6 commands)

```json
"lint:adrs": "node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0001-client-engine.md && pnpm lint:adr:0002 && pnpm lint:adr:0003 && pnpm lint:adr:0004 && pnpm lint:adr:0005 && pnpm lint:adr:0006"
```

Pattern works on: cmd.exe Windows (per `LOCAL-DEPLOY.md:5-7` operator runbook), PowerShell, bash, zsh, fish.

### Verified Pattern: traceable-reqs entry shape

(CITED: `traceable-reqs.toml:328-411` for v1.1 entries; same shape as `traceable-reqs.toml:38-69` v1.0 entries)

```toml
[[requirements]]
id = "REQ-MAP-02"
title = "Convention ADRs locked (gridSize, BNCentral chunk grid, tilesetSourceHash, LDtk version pin)"
required_stages = ["doc"]
```

**All 18 v1.1 entries already present** at `traceable-reqs.toml:326-417` (verified by reading the file). Phase 7 does NOT need to add them. Phase 7 MAY tighten `required_stages` on specific entries — current settings (CITED):

| REQ | Current `required_stages` | Phase 7 task? |
|-----|---------------------------|---------------|
| REQ-MAP-02 | `["doc"]` | None — Phase 7 is doc-only for the 4 ADRs |
| REQ-MAP-15 | `["doc", "impl"]` | None — already includes impl for the preflight script |
| REQ-HYG-01 | `["doc", "impl"]` | None — already includes impl for the audit script |
| REQ-HYG-02 | `["doc"]` | Consider tightening to `["doc", "impl"]` since Phase 7 ships the drift-lock script implementation |

**Recommendation:** Planner adjusts REQ-HYG-02 to `["doc", "impl"]` since Phase 7 ships actual impl code (`lint-no-req-placeholders.mjs`).

### Smoke-Test LDtk Authoring Recipe (D-22, D-24)

Per the LDtk 1.5.3 docs (`https://ldtk.io/docs/general/world/`) and schema (CITED: `MINIMAL_JSON_SCHEMA.json` worldLayout enum), the minimum operator steps:

1. **New Project** → File menu → New project. LDtk creates an untitled `.ldtk`.
2. **Save as** `maps/_smoke/synth-8000x6400.ldtk` (create the directory at save time).
3. **Project settings** (cog icon, top right):
   - Default grid size: `4` (D-01 — applies to all new layers)
   - World layout: **GridVania** (drop-down enum; valid values per schema are `Free`, `GridVania`, `LinearHorizontal`, `LinearVertical`)
   - World grid width: `2668` (NW/NE chunk width)
   - World grid height: `2132` (NW/N/NE chunk height)
   - (GridVania doesn't enforce equal chunk dims if you set `worldX/worldY` manually per level — author all 9 levels with explicit `worldX/worldY` per D-10)
4. **Create a Tile layer** (or IntGrid layer):
   - Layer type: Tile (or IntGrid+AutoLayer if testing auto-tile path — but D-22 says BORDERED only, no auto-tile, so plain Tile is fine)
   - Grid size: 4 (inherits from project default)
   - One BORDERED tile species; can be a solid color or any extracted floor sprite — any tile produces the same authoring lag, doesn't matter which
5. **Create 9 Levels:**
   - Level 0 (NW corner): identifier `chunk_nw`, worldX `0`, worldY `0`, pxWid `2668`, pxHei `2132`
   - Level 1 (N edge): worldX `2668`, worldY `0`, pxWid `2664`, pxHei `2132`
   - Level 2 (NE corner): worldX `5332`, worldY `0`, pxWid `2668`, pxHei `2132`
   - Level 3 (W edge): worldX `0`, worldY `2132`, pxWid `2668`, pxHei `2136`
   - Level 4 (CENTER): worldX `2668`, worldY `2132`, pxWid `2664`, pxHei `2136` ← the hub-equivalent chunk per D-09
   - Level 5 (E edge): worldX `5332`, worldY `2132`, pxWid `2668`, pxHei `2136`
   - Level 6 (SW corner): worldX `0`, worldY `4268`, pxWid `2668`, pxHei `2132`
   - Level 7 (S edge): worldX `2668`, worldY `4268`, pxWid `2664`, pxHei `2132`
   - Level 8 (SE corner): worldX `5332`, worldY `4268`, pxWid `2668`, pxHei `2132`
6. **Fill each level** with bordered tiles (paint the entire level with one tile species). Just enough content to exercise paint-stroke lag.
7. **Save and reopen** the project to verify the file isn't corrupted.
8. **Operator records subjective lag impressions** per chunk in SMOKE-NOTES.md.
9. **Operator takes a screenshot** of the LDtk editor showing the 3×3 world layout for SMOKE-NOTES.md attachment.

Total clicks: ~50 (project setup ~10, layer setup ~5, 9 levels × 3 clicks each = ~27, fill + save = ~5). Time estimate: 15–30 minutes for the recipe (excluding paint-fill time on slow chunks).

**Arithmetic check on chunk worldX:** `0 + 2668 + 2664 = 5332` (NE corner x); `5332 + 2668 = 8000` (right edge). Rows: `0 + 2132 + 2136 = 4268`; `4268 + 2132 = 6400`. Matches D-10 verbatim.

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| GitHub Actions full-path workflow gates deploys | Operator-local `pnpm preflight` + `flyctl deploy` | 2026-05-17 (06.7-09; GH Actions storage exhausted) | Phase 7's preflight script is the substitute; CI restoration deferred to v1.2+ per STATE.md |
| One-shot HYG-02 placeholder sweep | Drift-lock script (lint runs on every preflight) | Phase 7 (D-19→D-20 elevation) | Locks the clean state; new placeholders can't sneak in |
| `tooling/no-inline-origin.ts` invoked manually | Wired into preflight step 3 | Phase 7 (HYG-01) | Operator can't deploy without the D-63 gate running |
| LDtk version tracked nowhere | `tools/ldtk-version.txt` operator-trust file | Phase 7 (D-12) | Bumps require explicit edit + ADR 0012 reference |
| No tileset-source-hash discipline | sha256 hash carrier (TBD per Open Question Q1) declared per-tileset, asserted at loader init | Phase 7 (ADR 0010 lock) — wired in Phase 8 | Atlas regen without LDtk-side re-import becomes a HARD error, not silent rendering bug |
| BNCentral as single 8000×6400 LDtk level | 3×3 GridVania chunks (D-09/D-10) | Phase 7 (ADR 0011 lock) | Each chunk well under LDtk's ~5000 px lag threshold |

**Deprecated/outdated:**

- v1.0-era "Phase 7 Full Parity" — the **original** Phase 7 was relegated to backlog on 2026-05-17; the new Phase 7 (this one) is v1.1's "Workflow Smoke + Convention Locks". (CITED: `ROADMAP.md:118-124`)
- Phase 06.4's cycle-5 gap-closure script-broadening discussion for `no-inline-origin.ts` — superseded by 06.7 + D-18 (Phase 7 leaves the scope alone).
- The CONTEXT.md claim "F_String custom fields on tileset defs" (D-06) — see Open Question Q1; the LDtk 1.5.3 schema does not directly support this. Carrier choice must be made at plan time.

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | LDtk 1.5.3 supports per-tileset `F_String` custom field (CONTEXT.md D-06 verbatim) | User Constraints / Open Question Q1 | ADR 0010 needs different carrier — affects Phase 8 loader assertion logic. Likely answer: use sidecar JSON or `tags` array. |
| A2 | Operator's installed LDtk is 1.5.3 (matches `tools/ldtk-version.txt` to be created) | LDtk Version Pin | If operator has 1.5.x with x≠3, schema may differ subtly; ADR 0012 must say "≥ 1.5.3" or "1.5.3 exactly" |

**Note:** The recommendation in Pitfall 1 (sidecar JSON carrier) is `[CITED: ldtk.io MINIMAL_JSON_SCHEMA.json — TilesetDef field listing]`, not `[ASSUMED]`, because the schema's absence of a per-TilesetDef customFields surface is verifiable. The choice of WHICH carrier to use is `[ASSUMED]` until the operator confirms in plan-time discussion.

## Open Questions (RESOLVED)

All five open questions surfaced during research were resolved before plan-checker
verification (2026-05-20). Each line below cites the resolving artifact.

1. **Q1 (HIGH PRIORITY): What sha256 carrier on the LDtk side does ADR 0010 select?**
   - What we know: LDtk 1.5.3 schema (CITED: MINIMAL_JSON_SCHEMA.json) has NO `F_String` custom-field array on TilesetDef. Available carriers: (A) `tags` string-array on TilesetDef, (B) sidecar JSON file paired with each `.ldtk`, (C) `customData` on tile UID 0.
   - What's unclear: which carrier ADR 0010 should formalize.
   - Recommendation: Sidecar JSON (Option B). Keeps `.ldtk` clean; trivially scriptable in `lint-atlas-hash.mjs`; survives LDtk-editor edits. **Planner should confirm with operator in plan-time clarification round.**
   - **RESOLVED 2026-05-20:** Option B sidecar JSON locked. Operator-picked. Captured as CONTEXT.md D-06 (`maps/<world>.ldtk` ↔ `maps/<world>.tileset-hashes.json`, schema `{ "<TilesetDef.uid>": "<sha256-hex>" }`). Author target: Plan 07-02 / ADR 0010. Commit `5046393` (`docs(07): resolve Q1 — ADR 0010 carrier = sidecar JSON (Option B)`).

2. **Q2: Is the existing `traceable-reqs.toml` 18-entry v1.1 block authoritative, or does Phase 7 need to verify/regenerate?**
   - What we know: `traceable-reqs.toml:326-417` has all 18 entries (MAP-01..16 + HYG-01..02). `required_stages` are set per-req.
   - What's unclear: Were they added correctly during a prior phase, or does Phase 7 own verification/correction?
   - Recommendation: Phase 7 plan adds ONE task "Verify traceable-reqs.toml v1.1 entries match REQUIREMENTS.md titles + tighten HYG-02 `required_stages` to `[doc, impl]`". Cheap (~5 minutes); closes loop.
   - **RESOLVED 2026-05-20:** Recommendation accepted. Owner: Plan 07-05 (`Tighten traceable-reqs.toml REQ-HYG-02 → ["doc","impl"]`, single task, Wave 1, single-writer for `traceable-reqs.toml`). Tightening sequenced BEFORE Plan 07-06 ships the `[impl->REQ-HYG-02]` tag via `depends_on: [07-05]`.

3. **Q3: ADR ordering vs existing 0008 — confirm 0009 is the next slot?**
   - What we know: `docs/adr/` listing shows 0001 through 0008. `0009` is the next slot.
   - What's unclear: Is there any in-flight ADR claiming 0009 from another branch?
   - Recommendation: Check at plan execution time via `git fetch && git branch -a --list "*adr*"` before authoring. Reserve 0009/0010/0011/0012 in that order.
   - **RESOLVED 2026-05-20:** Slots 0009/0010/0011/0012 reserved in order. Owners: Plans 07-01 (0009 gridSize) / 07-02 (0010 tilesetSourceHash) / 07-03 (0011 BNCentral chunking) / 07-04 (0012 LDtk version pin). No in-flight ADR contention at HEAD (`5046393`).

4. **Q4: Does the operator's LDtk install on Windows produce the exact `worldX/worldY` per level if those fields are entered manually via the GridVania UI?**
   - What we know: Schema documents `worldX/worldY` as integers. GridVania mode permits manual placement.
   - What's unclear: Does the LDtk GUI snap worldX/worldY to grid? At gridSize=4 the values 2664, 2668, 2132, 2136 should all snap cleanly (all `%4==0`), but the editor may have its own internal alignment.
   - Recommendation: Operator verifies at smoke-test time; if alignment fails, ADR 0011 adjusts chunk dims to LDtk's snap multiple. SMOKE-NOTES.md documents observed behavior.
   - **RESOLVED 2026-05-20:** Empirically deferred to smoke test. Owner: Plan 07-08 Task 2 (operator UAT) — step 4 explicitly observes `worldX/worldY` of each authored level + records alignment behavior in `SMOKE-NOTES.md`. Escalation path: if LDtk snaps to a non-4 multiple, ADR 0011 chunk dims (D-10) re-open per D-25.

5. **Q5: HYG-02 sweep — D-19 claims "zero hits" but my grep returned 20+ hits. What's the truth?**
   - What we know: D-19 stated "confirmed via grep 2026-05-19"; my grep on the same date returned 20+ matches.
   - What's unclear: D-19 likely meant "zero LEGITIMATE-DEBT hits" (i.e. all hits are meta-references). My grep agrees if allowlist applied.
   - Recommendation: Plan ships `lint-no-req-placeholders.mjs` WITH allowlist included from commit-1. Don't try to "clean" the meta-references — they are legitimate documentation of the regex itself.
   - **RESOLVED 2026-05-20:** Recommendation accepted. Owner: Plan 07-06 Task 1 — script ships with hardcoded allowlist for meta-reference files (CLAUDE.md, STATE.md, PROJECT.md, this RESEARCH.md, CONTEXT.md, the lint script's own source, regex-documenting blocks in `docs/`). D-19 "zero hits" reading reconciled as "zero LEGITIMATE-DEBT hits".

## Environment Availability

Phase 7 has minimal external dependencies. Most tooling is built-in or already installed.

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node 22 | Preflight scripts + Vite + everything | ✓ (assumed per package.json engines) | 22.x | — |
| pnpm 10 | All `pnpm <script>` invocations | ✓ (per LOCAL-DEPLOY.md:30) | 10.x | — |
| `tsx` 4.21.0 | `tsx tooling/no-inline-origin.ts` | ✓ devDep | 4.21.0 | — |
| `traceable-reqs` CLI | `pnpm trace:check` | ✓ (per `package.json:67`) | (per lockfile) | — |
| `git` on PATH | `git grep` in `lint-no-req-placeholders.mjs` and `no-inline-origin.ts` | ✓ (assumed, repo is git) | any | — |
| LDtk 1.5.3 desktop app | Operator-authored smoke test (D-22) | ✗ (operator-machine only; not validated in this research) | — | Document "operator installs LDtk before running smoke test" in plan |
| `flyctl` | Documented in LOCAL-DEPLOY.md (no Phase 7 deploy required) | (operator-machine) | — | — |

**Missing dependencies with no fallback:** None. Phase 7 does NOT deploy.

**Missing dependencies with fallback:** LDtk install — operator handles before smoke test; if operator doesn't have LDtk 1.5.3 yet, the smoke test (which is a non-blocking PASS/FAIL per D-25) can be deferred briefly without blocking the ADR commits.

## Validation Architecture

> The `workflow.nyquist_validation` flag was not surfaced in this research's input — confirm at plan time. If enabled, this section governs Phase 7's test surface.

### Test Framework

| Property | Value |
|----------|-------|
| Framework | Node built-in `node --test` (already used by existing lint test files: `lint-asset-pipeline.test.mjs`, `lint-vite-env.test.mjs`, `lint-deploy-stack.test.mjs`) |
| Config file | None (Node test runner needs no config) |
| Quick run command | `node --test tools/scripts/lint-no-req-placeholders.test.mjs` |
| Full suite command | `node --test tools/scripts/*.test.mjs && pnpm trace:check && pnpm preflight` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|--------------|
| REQ-MAP-02 | Four ADRs exist + pass `lint-adr.mjs --no-matrix` | doc-lint | `pnpm lint:adr:0009 && pnpm lint:adr:0010 && pnpm lint:adr:0011 && pnpm lint:adr:0012` | ❌ Wave 0 (Phase 7 creates) |
| REQ-MAP-15 | `pnpm preflight` runs all 7 steps, exits non-zero on any failure | smoke | `pnpm preflight` | ❌ Wave 0 (Phase 7 creates the chain) |
| REQ-MAP-15 | Each preflight step is independently invokable | unit | `pnpm lint:no-req-placeholders && pnpm lint:atlas-hash && node tools/scripts/check-conversion-regression.mjs` | ❌ Wave 0 |
| REQ-MAP-15 | LDtk version pin step fails when `tools/ldtk-version.txt` is missing or empty | unit | `mv tools/ldtk-version.txt /tmp/ && pnpm preflight; (expect failure); mv /tmp/ldtk-version.txt tools/` | ❌ Wave 0 — needs unit test file |
| REQ-HYG-01 | `no-inline-origin.ts` invoked by preflight as step 3 | smoke | Inspect `package.json` `preflight` value for `gate:no-inline-origin` token | (manual grep) |
| REQ-HYG-01 | `no-inline-origin.ts` continues to fail when a violation is introduced | regression | Existing 06.4 contract — unchanged in Phase 7 | ✓ (existing) |
| REQ-HYG-02 | `lint-no-req-placeholders.mjs` exits 0 on current repo state (post-allowlist) | smoke | `pnpm lint:no-req-placeholders` | ❌ Wave 0 |
| REQ-HYG-02 | `lint-no-req-placeholders.mjs` exits 1 when a placeholder is introduced | unit | Synthetic fixture in a temp file; test asserts exit 1 | ❌ Wave 0 — needs test file |

### Sampling Rate

- **Per task commit:** `pnpm preflight` (the chain itself is the smoke surface)
- **Per wave merge:** `node --test tools/scripts/*.test.mjs && pnpm preflight`
- **Phase gate:** Full preflight + all 4 ADR lints green + smoke-test PASS in SMOKE-NOTES.md before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `tools/scripts/lint-no-req-placeholders.test.mjs` — unit test asserting allowlist behavior + synthetic violation detection
- [ ] `tools/scripts/lint-atlas-hash.test.mjs` — unit test asserting empty-maps/ early-return + (deferred) Phase-8 swap target
- [ ] `tools/scripts/check-conversion-regression.test.mjs` — unit test asserting Phase-7 stub exits 0 with skip message

*(No framework install needed — `node --test` is built-in.)*

## Security Domain

Phase 7 ships zero new attack surfaces. Audit is brief.

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | no | Phase 7 has no user-facing auth surface |
| V3 Session Management | no | No sessions |
| V4 Access Control | no | No new access-controlled resources |
| V5 Input Validation | yes (light) | Preflight inputs (file existence, file content non-empty for `ldtk-version.txt`) — fail-fast on bad input; no untrusted user input enters the chain |
| V6 Cryptography | yes (carry-over) | sha256 (atlas-hash stub) uses Node `crypto.createHash` — built-in, well-vetted. No custom crypto. ADR 0010 hash carrier is integrity-only, not secrecy — sha256 hex is appropriate. |
| V8 Data Protection | no | No secrets in Phase 7 artifacts. `tools/ldtk-version.txt` is plaintext version string. |
| V14 Configuration | yes | `tools/ldtk-version.txt` becomes a config file; documented in ADR 0012 |

### Known Threat Patterns for this stack

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Path traversal in `git grep` regex arg | Tampering | Pattern is hard-coded in `.mjs` source, not user-supplied |
| Allowlist bypass in `lint-no-req-placeholders.mjs` | Tampering | Allowlist is hard-coded array; reviewed on every PR touching the script |
| Hash carrier on TilesetDef edited away by malicious operator | Tampering | OUT OF SCOPE for v1.1 — operator-trust posture per ADR 0010; covered by operator-machine integrity, not protocol-level controls |
| Stale `ldtk-version.txt` pin | Tampering | Operator-trust per D-12; pin is opt-in convention. CI restoration in v1.2+ would automate enforcement. |

**Phase 7 conclusion:** No new attack surface. All security concerns are operator-trust posture inherited from v1.0 (Phase 06.7 client-trust carve-out, operator-local deploy doctrine).

## Project Constraints (from CLAUDE.md)

Directives extracted from `CLAUDE.md` that the planner MUST verify Phase 7 honors:

| Directive | Source | Phase 7 Compliance |
|-----------|--------|---------------------|
| **No new TypeScript before Phase 4** | CLAUDE.md Hard Rule 6 | ✓ — Phase 7 is post-Phase-4 (v1.1 milestone); TS is allowed; but Phase 7 ships no new TS regardless (only `.mjs` scripts and `.md` files) |
| **Floor tile dimensions = 44×40 px** | CLAUDE.md "Extracted Constants" | ✓ — ADR 0011 chunk dims (D-10) are multiples of 4 (honoring gridSize=4); ADR 0009 maps 44×40 → 11×10 cells (D-02) |
| **D-63 coordinate convention** | CLAUDE.md "Coordinate Conventions (D-63 — Plan 06.4)" | ✓ — Phase 7 only WIRES the existing `no-inline-origin.ts` script; does not modify it. D-18 explicitly preserves scope. |
| **Server-authoritative carve-out for movement** | CLAUDE.md Hard Rule 1 (Phase 06.7 carve-out) | ✓ — Phase 7 ships no runtime code; no impact |
| **Tagging contract: `[<doc>->REQ-X]` in MD, `[<impl>->REQ-X]` in TS/JS comments** | CLAUDE.md "Tagging contract" | New files MUST tag: ADRs tag `[doc->REQ-MAP-02]`; new `.mjs` scripts tag `[impl->REQ-HYG-02]`, `[impl->REQ-MAP-15]` as appropriate; LOCAL-DEPLOY.md section tags `[doc->REQ-MAP-15]` |
| **`traceable-reqs.toml` is authoritative for canonical REQ-IDs** | CLAUDE.md "Requirements Traceability" | ✓ — 18 v1.1 entries already present; Phase 7 verifies + optionally tightens HYG-02 stages |
| **ADRs live at `docs/adr/NNNN-title.md`** | CLAUDE.md "Conventions" | ✓ — Phase 7 ADRs follow this pattern (0009, 0010, 0011, 0012) |
| **`pnpm trace:check` should pass locally before claiming phase complete** | CLAUDE.md "Requirements Traceability" §Verifying | ✓ — preflight step 4 enforces this on every preflight run |
| **NEVER hard-code 32×32 or any non-44×40 tile size** | CLAUDE.md "Extracted Constants" | ✓ — Phase 7 doesn't author tile sizes; chunk dims are pixel-counts unrelated to tile pitch |
| **`legacy/` is not pushed publicly until cleared** | CLAUDE.md Hard Rule 8 | ✓ — Phase 7 doesn't touch `legacy/` |

## Sources

### Primary (HIGH confidence — `[CITED]` and `[VERIFIED]` claims)

- `tools/asset-catalog/scripts/lint-adr.mjs` (lines 1-164) — ADR lint contract
- `tooling/no-inline-origin.ts` (lines 1-87) — HYG-01 audit script (already exists, leave unchanged)
- `tools/scripts/lint-no-clipboard-rce.mjs` (lines 1-69) — `.mjs` lint-script template reference
- `tools/scripts/lint-room-layout.mjs` (lines 1-60) — `.mjs` lint-script header convention reference
- `package.json` (lines 1-96) — script naming + `&&` chain convention
- `traceable-reqs.toml` (lines 1-417) — REQ-IDs, scan roots, current `required_stages`
- `docs/adr/0004-room-hot-reload.md` (lines 1-180) — ADR template reference (Michael Nygard format)
- `docs/adr/0007-phase-6-protocol-amendments.md` (lines 1-50) — ADR template reference
- `docs/adr/0008-canvas-base-resolution.md` (lines 1-117) — ADR template reference (most recent ADR)
- `docs/deploy/LOCAL-DEPLOY.md` (lines 1-100) — operator runbook integration target
- `.planning/REQUIREMENTS.md` — Phase 7 requirement texts (MAP-02, MAP-15, HYG-01, HYG-02)
- `.planning/ROADMAP.md` (lines 40-51) — Phase 7 Success Criteria
- `.planning/phases/07-workflow-smoke-convention-locks/07-CONTEXT.md` — D-01..D-25 locked decisions
- `.planning/research/v1.1/SUMMARY.md`, `PITFALLS.md`, `ARCHITECTURE.md`, `STACK.md` — milestone-level research
- `CLAUDE.md` — project constraints, extracted constants, D-63 convention, tagging contract
- LDtk official schema: `https://ldtk.io/files/MINIMAL_JSON_SCHEMA.json` — verified TilesetDef fields, worldLayout enum values (`Free`, `GridVania`, `LinearHorizontal`, `LinearVertical`), Level fields (`worldX`, `worldY`, `pxWid`, `pxHei`)

### Secondary (MEDIUM confidence)

- WebFetch of `https://ldtk.io/json/` and sub-anchors — LDtk schema interpretation. Authoritative source but page-anchor extraction is imprecise.

### Tertiary (LOW confidence)

- None — Phase 7's scope is small enough that all claims have a primary source.

## Metadata

**Confidence breakdown:**
- ADR shape compliance: HIGH — `lint-adr.mjs` read in full + 3 existing ADRs read.
- Preflight wiring details: HIGH — `package.json` read in full + existing scripts confirmed.
- HYG-02 drift-lock regex + allowlist: HIGH — own grep across repo revealed the false-positive surface, allowlist concrete.
- LDtk schema for ADR 0010 carrier: MEDIUM — schema source authoritative but D-06's `F_String` claim contradicts schema; planner needs to confirm carrier.
- Smoke-test recipe: MEDIUM — recipe is correct per LDtk docs, but operator UI behavior not directly verified (Q4).
- Pitfalls + anti-patterns: HIGH — grounded in v1.1 PITFALLS.md + Phase 7 CONTEXT.md + existing repo state.

**Research date:** 2026-05-19
**Valid until:** 2026-06-19 (30 days; this phase is convention-locking with stable inputs; longer than fast-moving milestones)

[doc->REQ-MAP-02] [doc->REQ-MAP-15] [doc->REQ-HYG-01] [doc->REQ-HYG-02]
