---
phase: 07-workflow-smoke-convention-locks
plan: 02
subsystem: adr
tags: [adr, ldtk, tileset, hash, sidecar, integrity, sha256, convention-lock]
requirements: [MAP-02]
dependency_graph:
  requires:
    - "07-CONTEXT.md D-05..D-08 (Q1 resolved 2026-05-20 → Option B sidecar JSON)"
    - "07-PATTERNS.md Pattern B (Michael Nygard ADR template via ADR 0008/0004)"
    - "docs/adr/0004-room-hot-reload.md (sibling crypto/integrity fail-loud precedent)"
    - "docs/adr/0008-canvas-base-resolution.md (Pattern B header-ordering template)"
    - "tools/asset-catalog/scripts/lint-adr.mjs --no-matrix"
  provides:
    - "Locked tileset-hash drift-detection convention (sha256 + sidecar JSON + HARD throw)"
    - "Phase 8 LdtkLoader.ts inherits the HARD-throw + sidecar-parse contract"
    - "Phase 8 lint-atlas-hash.mjs activation target (read sidecar, compare to live sha256)"
  affects:
    - "Phase 8 LdtkLoader.ts (consumer; throws on mismatch)"
    - "Phase 8 tools/scripts/lint-atlas-hash.mjs (activates against sidecars when maps/ has real .ldtk)"
    - "Phase 8 (future) tools/scripts/update-tileset-hashes.mjs (re-hash helper)"
tech-stack:
  added: []
  patterns:
    - "Pattern B ADR (Michael Nygard format with --no-matrix lint)"
    - "Pattern D doc-tagging ([doc->REQ-MAP-02] on its own line)"
    - "Sidecar-JSON content-binding (paired-file integrity convention)"
key-files:
  created:
    - docs/adr/0010-ldtk-tileset-source-hash.md
  modified: []
decisions:
  - "D-05 locked: hash algorithm = sha256 (hex, 64 chars, lowercase) — Node crypto built-in, zero new deps"
  - "D-06 locked: carrier = sidecar JSON file maps/<world>.tileset-hashes.json with schema { \"<TilesetDef.uid>\": \"<sha256-hex>\" }"
  - "D-07 locked: drift behavior = HARD throw + refuse to load (mirrors ADR 0004 Ed25519 posture)"
  - "D-08 locked: ADR file = docs/adr/0010-ldtk-tileset-source-hash.md"
metrics:
  duration_min: 6
  tasks_completed: 1
  files_changed: 1
  completed_date: 2026-05-20
---

# Phase 07 Plan 02: ADR 0010 LDtk Tileset Source Hash Summary

**One-liner:** Locks the `tilesetSourceHash` convention as sha256 hex carried in a sidecar JSON file (`maps/<world>.tileset-hashes.json`) with HARD throw on mismatch, formalizing the Q1 → Option B resolution from commit 5046393 ahead of Phase 8's `LdtkLoader.ts`.

## Objective

Author ADR 0010 locking three sub-decisions before any `.ldtk` content references a tileset hash:
1. **sha256** as the hash algorithm (D-05).
2. **Sidecar JSON file** as the carrier, with concrete schema `{ "<TilesetDef.uid>": "<sha256-hex>" }` (D-06).
3. **HARD throw + refuse to load** as the drift behavior, mirroring ADR 0004's Ed25519-sig-fail posture (D-07).

The ADR must pass `lint-adr.mjs --no-matrix`, carry `[doc->REQ-MAP-02]`, and absorb the four CONTEXT.md decisions (D-05..D-08) that the original v1 CONTEXT.md left in tension (because v1 incorrectly assumed LDtk's `TilesetDef` had a `customFields[]` array — verified absent in LDtk 1.5.3).

## What Was Built

- **`docs/adr/0010-ldtk-tileset-source-hash.md`** — 213 lines, Michael Nygard format, ADR 0008-style header ordering (Date + Phase + blank + `[<doc>->]` tag + blank + `## Status`). Decision section contains three numbered sub-decisions matching D-05/D-06/D-07. Sidecar schema `{ "<TilesetDef.uid>": "<sha256-hex>" }` appears literally in the Decision block. Alternatives section rejects the four non-chosen carriers with explicit reasons (`customFields[]` not in schema, `customLevelFields` granularity wrong, PNG-filename hash breaks editor caching, git-LFS overkill). References cite ADR 0004, 07-CONTEXT.md, PITFALLS.md, the LDtk JSON Schema URL, and commit 5046393.

## How It Was Built

Pattern B from `07-PATTERNS.md` applied verbatim. Header ordering mirrors ADR 0008 (most-recent style) per PATTERNS.md note: "Phase 7 ADRs should mirror 0008's 'Date + Phase + blank + tag + blank + ## Status' ordering." The crypto-sibling framing (fail-loud posture, sha256-payload discipline) borrowed directly from ADR 0004 lines 1-60. No re-litigation of Q1 — the Decision section locks Option B per the operator's 2026-05-20 resolution (commit 5046393) and the Q1 history is summarized in the Context section as historical record only.

## Verification

```
$ node tools/asset-catalog/scripts/lint-adr.mjs docs/adr/0010-ldtk-tileset-source-hash.md --no-matrix
OK: ADR docs/adr/0010-ldtk-tileset-source-hash.md validated (no-matrix mode — Michael Nygard sections present)
EXIT: 0
```

Acceptance-criteria scan (from PLAN.md `<acceptance_criteria>` block):

| Criterion | Result |
|----------|--------|
| File `docs/adr/0010-ldtk-tileset-source-hash.md` exists | ✓ |
| H1 line `# ADR 0010:` present | ✓ (1 hit) |
| `[doc->REQ-MAP-02]` on its own line | ✓ (1 hit, line 5) |
| All six required H2 sections present (`Status`/`Context`/`Decision`/`Consequences`/`Alternatives considered`/`References`) | ✓ (1 hit each) |
| `## Consequences` contains `### Positive` / `### Negative` / `### Neutral` | ✓ (1 hit each) |
| `## Decision` contains literal `sha256` | ✓ (12 hits across Decision + Consequences) |
| `## Decision` contains sidecar path `maps/<world>.tileset-hashes.json` | ✓ |
| `## Decision` contains sidecar schema `"<TilesetDef.uid>"` + `"<sha256-hex>"` | ✓ (1 hit each, literal JSON block) |
| `## Decision` describes HARD throw / refuse-to-load (D-07) | ✓ (9 throw/refuse hits) |
| `lint-adr.mjs --no-matrix` exits 0 | ✓ |
| No `## Supersedes` block | ✓ (0 hits) |
| No MX-* citations | ✓ (0 hits) |
| `sidecar` mentioned ≥ 2 | ✓ (25 hits) |

Verification block from PLAN.md:

```
$ grep -c "\[<doc>->REQ-MAP-02\]" docs/adr/0010-ldtk-tileset-source-hash.md   → 1 (≥ 1) ✓
$ grep -c "sha256" docs/adr/0010-ldtk-tileset-source-hash.md                → 12 (≥ 2) ✓
$ grep -c "sidecar" docs/adr/0010-ldtk-tileset-source-hash.md               → 25 (≥ 2) ✓
```

## Deviations from Plan

None — plan executed exactly as written. The PLAN.md `<action>` block specified the exact section contents and sub-decision numbering; no Rule 1/2/3 fixes were required.

## Decisions Made

All four decisions were pre-locked by the operator in 07-CONTEXT.md (D-05..D-08) and formalized verbatim in the ADR:

1. **Algorithm = sha256.** Node crypto built-in; zero new dependencies; matches Ed25519-payload-hashing precedent already in `tools/room-converter`.
2. **Carrier = sidecar JSON file.** Paired with `.ldtk` as `<world>.tileset-hashes.json`; schema `{ "<TilesetDef.uid>": "<sha256-hex>" }`. Editor-clean, survives LDtk round-trips, trivially scriptable.
3. **Drift = HARD throw.** Phase 8 `LdtkLoader.ts` throws synchronously naming the drifted tileset uid + expected hex + got hex + sidecar path. No fallback, no degraded-mode load.
4. **ADR file = `docs/adr/0010-ldtk-tileset-source-hash.md`.** Continues the 0009/0010/0011/0012 sequence locked in CONTEXT.md.

## Commits

| Commit | Type | Message |
|--------|------|---------|
| `5500c46` | docs | docs(07-02): add ADR 0010 LDtk tileset source hash sidecar convention |

## Tasks Completed

- [x] Task 1: Author ADR 0010 tilesetSourceHash sidecar JSON convention (commit `5500c46`)

## Known Stubs

None — this plan ships only a Markdown ADR. The Phase 8 consumers (`LdtkLoader.ts` HARD throw + `lint-atlas-hash.mjs` activation) are intentionally out of scope per CONTEXT.md and are tracked downstream.

## Threat Flags

None. The plan introduces zero new network surface, zero auth paths, zero file-access patterns at trust boundaries, and zero schema changes. ADR 0010 itself locks a mitigation (T-07-02-T in the PLAN.md `<threat_model>`) — the convention is the mitigation, not new surface. T-07-02-SC (supply-chain) is satisfied trivially because Plan 07-02 ships zero new npm dependencies.

## Self-Check: PASSED

- ✓ `docs/adr/0010-ldtk-tileset-source-hash.md` exists (post-commit).
- ✓ Commit `5500c46` exists in `git log` on branch `worktree-agent-a57c84cef36b4068d`.
- ✓ `lint-adr.mjs --no-matrix` exits 0.
- ✓ All eleven `<acceptance_criteria>` items from PLAN.md pass.
- ✓ All four `<verification>` greps from PLAN.md exceed their minimum counts.
