# ADR 0012: LDtk version pin

**Date:** 2026-05-20
**Phase:** 07 (Workflow Smoke + Convention Locks, Plan 07-04)

[doc->REQ-MAP-02]

## Status

**Accepted** — locks the LDtk editor version-pin convention to a single-line
plaintext file `tools/ldtk-version.txt` with operator-trust posture (no
programmatic LDtk-install introspection).

Re-evaluation gate: LDtk ships a stable version-query CLI/registry surface on
Windows (would enable a portable programmatic check) **OR** operator UAT
reveals schema drift between LDtk versions that the file-pin alone cannot
catch (e.g., a `.ldtk` file authored with an unannounced LDtk upgrade passes
preflight but breaks QuickType-generated `LdtkJson.ts` types downstream).
Absent either trigger, locked through v1.2.

## Context

LDtk's `.ldtk` JSON schema evolves across editor versions. Across recent
LDtk releases the schema has bumped a top-level `jsonVersion` field, added
new layer-instance fields, renamed certain enum tags, and adjusted
`customFields[]` shapes on `LayerDef` and `EntityDef`. Each schema bump
silently changes the surface that downstream tooling must consume.

REBNO must pin to one LDtk editor version because:

1. **QuickType-generated types must match what the operator's editor emits.**
   Phase 8 generates `packages/protocol/src/ldtk/LdtkJson.ts` from the
   LDtk JSON schema for a *specific* LDtk version. If the operator opens a
   `.ldtk` file in a different LDtk version, saves it, and the editor
   rewrites it with the new schema, the generated types may no longer
   match — broken `runtime` parsing without a clear error message.
2. **The convention-lock ADRs in this phase (0009 gridSize=4 unified, 0010
   sidecar `tileset-hashes.json`, 0011 BNCentral 3×3 GridVania chunking)
   all assume LDtk 1.5.3 semantics.** A future LDtk version that
   redefines GridVania world-layout coordinates or `jsonVersion`-bumps
   `TilesetDef.uid` typing would invalidate those decisions.

**Why not programmatic introspection?** LDtk is a desktop GUI Electron app
distributed via deepnight.net / itch.io. It has no stable CLI surface for
version querying on Windows: no `ldtk --version`, no per-OS package-manager
registry entry, no Windows registry key, no `Get-AppxPackage`-style probe
that survives across install methods (manual download vs itch.io app vs
portable). Any "smart" introspection script would need OS-specific branches
plus filesystem heuristics (Program Files vs `%LOCALAPPDATA%` vs portable
extract dirs) and would still fail when the operator runs a portable copy
from a USB drive. The cross-platform fragility cost exceeds the value.

Phase 06.7 already established the relevant carve-over posture: where
automated detection is brittle/fragile, prefer file-based assertion plus
operator discipline. The original 06.7 carve was for client motion
authority (server stores client-reported state and broadcasts to peers,
anti-cheat deferred). The analogous posture here is: trust the operator to
keep `tools/ldtk-version.txt` synchronized with the locally-installed LDtk
version when bumping. Cf. `keys/rebno-room-signing.ed25519.pub.pem` —
operator-managed file precedent already shipped in the repo.

## Decision

1. **Pin file location and content.** The LDtk version pin lives in a
   single file at `tools/ldtk-version.txt`. The file contains exactly one
   line with the version string (currently `1.5.3`) followed by a trailing
   newline. No comments (`#` or `//`), no additional lines, no BOM. The
   format is load-bearing because `pnpm preflight` step 1 (wired in Plan
   07-06) consumes the file as:

   ```js
   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);
   ```

   The preflight step performs *no* programmatic check against the
   installed LDtk binary. It only asserts the file is non-empty and echoes
   the pinned value. This is the operator-trust posture: the file is the
   contract; the operator is responsible for keeping it accurate. Matches
   the 06.7 client-trust carve-over.

2. **ADR file location.** This ADR lives at
   `docs/adr/0012-ldtk-version-pin.md`. It references
   `tools/ldtk-version.txt` as the live pin. The ADR cites the version
   `1.5.3` for context but does **not** duplicate the value in a
   load-bearing form: bumping LDtk = editing `tools/ldtk-version.txt`, not
   editing this ADR. The ADR's role is to document the convention and the
   re-evaluation gate, not to track the current version.

## Consequences

### Positive

- **Zero new dependencies.** No introspection library, no LDtk SDK, no
  cross-platform shell shimming. The pin file is plain UTF-8 text.
- **Portable across Windows / Linux / macOS.** `readFileSync(...).trim()`
  has identical semantics on every Node-supported OS. No conditional
  branches.
- **Bumping LDtk is a one-line edit.** Operator opens
  `tools/ldtk-version.txt`, replaces `1.5.3` with `1.5.4` (or whatever),
  saves. Done. No code change, no regenerated types committed at the same
  time (that's the operator's separate discipline — see Negative below).
- **Preflight step 1 is a 3-line inline check.** No script file, no test
  harness, no edge cases. The plan in 07-06 keeps the chain visible in
  `package.json` so drift requires an explicit edit.
- **Pin survives editor round-trips.** LDtk cannot accidentally rewrite
  the pin file; it lives outside `maps/` and outside `.ldtk` JSON.

### Negative

- **Operator must remember to edit `tools/ldtk-version.txt` when upgrading
  LDtk locally.** If forgotten, the preflight passes against a stale pin
  while the operator's editor is emitting a different schema. Worst case:
  Phase 8's QuickType-generated `LdtkJson.ts` no longer matches what the
  editor writes, and runtime `.ldtk` parsing fails (or worse, silently
  drops fields) with no clear error trail back to "you forgot to bump the
  pin."

  Mitigation: bump-on-upgrade is documented as an operator-discipline rule
  in `docs/deploy/LOCAL-DEPLOY.md` (Plan 07-06 owns that section, alongside
  the preflight gate). The qualitative re-evaluation gate above
  (operator UAT reveals schema drift) covers detection.

### Neutral

- **Any future tooling that needs the pin programmatically reads
  `tools/ldtk-version.txt`, not this ADR.** Single source of truth for the
  value. The ADR documents the *convention*; the file holds the *value*.
- **Pin file is non-secret.** LDtk version is public information. No
  security boundary crossed; no secret-management posture required.

## Alternatives considered

- **Programmatic LDtk-install version introspection** —
  *Rejected*: no stable CLI / registry / package-manager version-query
  surface on Windows; LDtk is a desktop GUI Electron app distributed via
  manual download / itch.io / portable extract. Any introspection script
  would need OS-specific branches and would still fail for portable
  installs. Cross-platform fragility exceeds the value of "automatic"
  detection.
- **Embed pin in `package.json`** (e.g. as a custom `ldtkVersion` field
  or under `engines`) — *Rejected*: harder to bump independently of npm
  workspace dep changes; mixes editor version with workspace dep versions;
  Node's `engines` field has well-defined semantics for engine versions
  (Node, npm) and adding non-standard sibling fields invites tooling
  confusion. Also: bumping the pin would trigger spurious diffs in the
  `package.json` review surface that's already busy.
- **Embed pin in `tools/ldtk-version.json`** — *Rejected*: JSON requires
  quoting, structure, and parsing. The single-line plaintext is the
  minimum surface that satisfies the preflight consumer; adding JSON
  structure invites schema sprawl ("can we add a `notes` field?").
- **Use a git tag for the pin** (e.g. `git tag ldtk/v1.5.3`) —
  *Rejected*: invisible to `readFileSync`; preflight would need to shell
  out to `git tag --list` and parse the output. Git tags also cannot be
  bumped without push/pull synchronization across the operator's
  worktrees. The file-based approach is strictly simpler.

## References

- `tools/ldtk-version.txt` — the live pin file (this ADR's load-bearing
  artifact).
- `keys/rebno-room-signing.ed25519.pub.pem` — operator-managed file
  precedent in the repo; analog operator-trust posture.
- `.planning/research/v1.1/STACK.md` — LDtk 1.5.3 pin source; documents
  assumption `[A2]` ("Operator's installed LDtk is 1.5.3").
- `.planning/phases/07-workflow-smoke-convention-locks/07-CONTEXT.md`
  §"LDtk Version Pin ADR" decisions D-12 (pin file location and content)
  and D-13 (ADR file location).
- `docs/deploy/LOCAL-DEPLOY.md` — operator runbook; Plan 07-06 adds the
  `## Preflight gate (MANDATORY)` section that documents the
  bump-on-upgrade rule.
- `CLAUDE.md` Hard Rule 7 — modern decompilers cannot read GM 5.3a;
  era-pinning discipline (different domain, same principle: pin to the
  version that emits the schema you're consuming).
