// SPDX-License-Identifier: CC-BY-SA-4.0
// SPDX-FileCopyrightText: 2025-2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
= Migration plan — organised by compaction boundary
:toc: macro

[IMPORTANT]
====
*This file is the durable spine of the  -> AffineScript -> wasm
migration.* It is written to survive context compaction: the repo is the
memory, not the model's window. Each PHASE below ends at a *committed,
verified checkpoint* and updates the *Ledger* at the bottom. After any
compaction, the resuming agent reads (1) this file's Ledger and (2) the
latest `proposals/idaptik/migration-map.json`, and continues — it does not
rely on the auto-summary.

Cadence: roughly one PHASE per context window ("to compact"). Each PHASE =
goal + deliverables + a green checkpoint + a one-line handoff. The arc runs
until "CLEAR": the migratable corpus is exhausted, the two compiler walls
are down, and the cutover has retired the  shadows.
====

toc::[]

== Standing gates (re-check every phase)

* *Decision gate 1 — production language.* The evangelist tools + harnesses
  are Deno trials today; production should be Rust or AffineScript (owner's
  call). Until decided: keep the Deno trials, do NOT re-author. Blocks only
  the "harden the tooling" sub-tasks, not the migration.
* *Decision gate 2 — evangelist home repo* (dedicated repo vs under
  `nextgen-languages`). Cosmetic; blocks nothing.
* *Access gate — idaptik write-access.* Current rule: writes land in
  affinescript only; everything idaptik-bound is *staged as applyable
  units* under `proposals/idaptik/`. The actual *apply* + *cutover* steps
  (Phases C+ "apply", Phase Ω) need either idaptik opened for writes or the
  owner accepting the staged patches. Until then, "migrated" means
  "verified + staged + applyable", not "applied".

== The two walls (what ultimately gates "CLEAR")

. *Variable-string backend* — `String.length`, read-side *indexing*
  (`string_char_code_at` / `char_to_int`, slice 1), single-byte *construction*
  (`string_from_char_code`, slice 2), *runtime-length substring* (`string_sub`,
  slice 3), *ASCII case-folding* (`to_lowercase` / `to_uppercase`, slice 4),
  *whitespace trimming* (`trim`, slice 5), *substring search* (`string_find`,
  slice 6), and *decimal rendering* (`int_to_string`, slice 7) now lower to
  wasm (Phase F, see Ledger) — *the clean lane is closed*; only concat / `++`
  and polymorphic `slice` remain, gated on a type-access design decision (see
  Ledger F+). Gates every STRING-GATED kernel (e.g. `Kernel_IO`, `DeviceType`,
  i18n) whose work is building/transforming strings rather than scanning them.
  Lives
  in *this* repo's compiler, so it is
  attackable here.
. *Effect codegen* — module-level mutable state / `Date.now` / `Console.log`
  cannot lower ("references an unbound binding"). Gates every EFFECT-GATED
  kernel (e.g. `Kernel_Quantum`). Also a compiler task.

Until a wall falls, its kernels stay staged with the honest
"compiler-gated, not effort-gated" label.

== Phases

=== PHASE A — now -> compact 1 : consolidate + make the plan durable
Goal: a clean baseline that compaction can't lose.

* Write this file + the Ledger (done by creating it).
* Settle PR #533 (evangelist toolkit): CI green; leave as draft for owner
  review (merge on owner word).
* Fold the three architecture artefacts (`multiplayer-determinism.adoc`,
  `state-vs-digest.svg`, `game-vs-deepspace.svg`) into the determinism doc
  as a captured "latency regimes" appendix.
* Record the standing gates above.

*Checkpoint:* plan + ledger committed & pushed; staged work coherent.
*Handoff:* "Read §Ledger; next is PHASE B (full-corpus triage)."

=== PHASE B — compact 1 -> 2 : broad sweep (the master worklist)
Goal: know the whole backlog, prioritised.

* Run `affine-migratability` over all ~571 idaptik `.res`; bucket by area
  (`src/app`, `shared/src`, `vm`, `src/app/multiplayer`, …) x verdict
  (MIGRATABLE-NOW / STRING-GATED / EFFECT-GATED) x shape (pure-integer core
  / binding shim / render-glue).
* Emit `proposals/idaptik/migration-map.adoc` (human) +
  `migration-map.json` (machine, the resumable worklist) + the headline %.
* Order the MIGRATABLE-NOW set into clusters: leaf pure-integer cores
  first, then up the dependency tree.

*Checkpoint:* full triage committed; backlog + order fixed.
*Handoff:* "worklist = migration-map.json; cluster 1 = <top N>; PHASE C."

=== PHASE C — compact 2 -> 3 : deep wave 1 (first clean cluster, end-to-end)
Goal: migrate + verify the first batch via the 4-gate recipe.

The *recipe* (every kernel, every wave):
. re-decompose `.res` -> `.affine` (NOT transliterate — collapse incidental
  Promise/async, thread service-locator state as params, enum the options,
  hand-ladder int rendering, keep wire bytes identical);
. `affinescript compile` -> wasm (gate 1: builds);
. `affine-parity` oracle-vs-wasm sweep green (gate 2: parity);
. `echo-boundary` proves each host-boundary encoding LOSSLESS or
  declared-clamp (gate 3: faithful);
. `affine-assail` clean — no undeclared clamp / unguarded decoder (gate 4).

* Apply recipe to cluster 1 (~6-10 pure-integer cores). Stage each under
  `proposals/idaptik/migrated/<name>/` with `.affine` + harness + evidence.

*Checkpoint:* cluster 1 four-gate-green + staged. Ledger % updated.
*Handoff:* "cluster 1 done (list); cluster 2 next; recipe in §Phase C."

=== PHASE D — compact 3 -> 4 : deep wave 2 + the TS-0 harness path
Goal: more kernels + start retiring the 17.6k LOC of TS harness.

* Cluster 2 of MIGRATABLE-NOW (same recipe).
* Trial `--deno-esm` struct -> `export class` + `extern fn` lowering;
  re-author ONE parity harness in AffineScript -> Deno-ESM; prove it
  reproduces the TS harness's verdict. TS-0-for-harnesses becomes real.

*Checkpoint:* cluster 2 green + the AffineScript-authored harness PoC.

=== PHASE E — compact 4 -> 5 : the multiplayer determinism spine (wasmex + SNIFS)
Goal: make server-authoritative determinism concrete before the big
multiplayer-client migration.

* `vm.affine` reversible counter; run the *identical* wasm in Deno (client)
  and under SNIFS/`wasmex` in a one-file Elixir (server); demonstrate
  identical step, reverse (rollback), and the per-tick *hash tripwire*
  catching an injected desync.

*Checkpoint:* same-binary-both-sides PoC + hash-tripwire demo verified.

=== PHASE F — compact 5 -> 6 : attack wall 1, slice 1 (variable-string backend)
Goal: stop documenting the wall; start removing it (compiler work, in scope).

* Implement `String.length` + string indexing end-to-end in the compiler
  (lexer/typer/codegen) on the `[len:i32 LE][utf8]` read-side ABI, with
  tests; demonstrate a previously STRING-GATED kernel now builds +
  parity-greens.

*Checkpoint:* first string primitive lands (compiler PR) + one string-kernel
unblocked.

=== PHASES G..N — compact -> compact : the repeating grind
Each window, in order:
. migrate the next cluster the latest compiler capability unblocked (recipe);
. advance the next wall-slice (`startsWith` -> `fromCharCode`; then the
  effect-codegen slices for `Date.now`/log/module-state);
. re-run the evangelist readiness map (the % climbs; gated counts fall);
. update the Ledger.

Repeat until the MIGRATABLE set is exhausted and both walls are down — at
which point `Kernel_IO` (string) and `Kernel_Quantum` (effect) finally fall.

=== PHASE Ω — -> CLEAR : cutover + extinction (the goodbye)
Goal: flip the switches, delete the shadows. (Gated on idaptik write-access;
until then each cutover is a staged applyable bundle for sign-off.)

* Per area, once all cores are parity-green + bindings wired: enable the
  `FeaturePacks` flag, delete the `.res` shadows, drop the TS harnesses,
  remove the lone `.py` holdout; re-run the census ( -> 0 in that
  area).
* Final census target:  0 in migrated areas, AffineScript primary,
  TypeScript 0, Python 0; agreed-keep set (Elixir/Zig/Idris2/Julia) intact.

*CLEAR* = migratable corpus migrated + applied, walls down, shadows retired.

== Model per phase (advise at each compact)

Heuristic:

* *Opus* — re-decomposition design, compiler-internals (the walls),
  novel cross-runtime integration, cutover review. Anywhere a wrong or
  subtle call is expensive. ("Fast" Opus when you want that reasoning with
  quicker output.)
* *Sonnet* — known-pattern multi-file grinding: the triage sweep, the rote
  waves once the recipe is set, the mechanical cutover apply.
* *Haiku* — pure mechanical sub-steps (bulk tool runs, boilerplate, status
  checks). Rarely a whole phase.

[cols="1,1,3",options="header"]
|===
| Phase | Model | Why
| A consolidate/plan | Opus | synthesis + architecture
| B broad triage | *Sonnet* | run one tool over ~571 files, bucket + prioritise; systematic, low-novelty
| C/D deep waves | *Opus* for the re-decomposition; Sonnet once rote | re-decompose is subtle (a transliteration is rejected); the 4 gates are mechanical
| E wasmex/SNIFS PoC | *Opus* | novel Elixir+wasm+Deno integration + determinism reasoning
| F + walls (compiler) | *Opus* | real OCaml compiler engineering (lexer/typer/codegen/ABI), correctness-critical
| G..N grind | alternate: *Sonnet* (migrate) / *Opus* (wall-slice) | per window
| Ω cutover | *Sonnet* to execute, *Opus* to sign off the diff | high-stakes deletes; mechanical apply
|===

== Ledger (each phase updates this; the resume point lives here)

[cols="1,1,3",options="header"]
|===
| Phase | State | Notes
| Pre | DONE | #531 echo proof (merged); #532 migration practice + guide + proposals (merged); #533 evangelist toolkit (*merged* 2026-06-05).
| A | DONE | Plan + model-per-phase guidance written; determinism doc gained a latency-regimes appendix (verify-don't-transfer + game-vs-deep-space + the 2 SVGs); #533 merged. NEXT: PHASE B.
| B | DONE | Full-corpus triage complete (2026-06-05). 571 files: *389 MIGRATABLE NOW (68%)*, 71 STRING-GATED (12%), 111 EFFECT-GATED (19%). Non-test: 358 files, 196 migratable (55%). Clusters C1–C12 ordered, leaf-first. Worklist: `proposals/idaptik/migration-map.json`. NEXT: PHASE C — cluster 1 = C1 (shared/src, 11 files) + C2 (vm instructions, 31 files). Switch to *Opus* for re-decomposition.
| C (C1) | DONE | Deep wave 1 complete (2026-06-05, Opus). Cluster C1 re-decomposed: *8 pure-integer kernels* staged under `proposals/idaptik/migrated/` (DeviceType, PuzzleFormat, PortNames, GameEvent, Kernel_Compute, Kernel, RetryPolicy, Diagnostics); 3 C1 files are host-side "senses" with no brain (Coprocessor_Backends, PortNamesCoprocessor, DLCLoader). *Four gates green:* 8/8 compile, 1223/1223 parity, 6 echo-boundary proofs (agda exit 0; 3 LOSSLESS + 3 CONTROLLED-LOSS), 8/8 assail-clean. Evidence: `migrated/EVIDENCE.adoc`. The per-shape recipe is now established (enum-taxonomy / status-gate / classifier / predicate). NEXT: wave 2 = C2.
| D (C2 wave 1) | DONE | Deep wave 2 (2026-06-05, Opus). Cluster C2 *wave 1* — the reversible VM value-transform opcodes — re-decomposed into *4 kernels* under `proposals/idaptik/migrated/` (VmArith, VmBitwise, VmAncilla, VmInstruction), covering 11 opcodes (Add/Sub/Negate/Noop/Swap/Flip/Xor/Rol/Ror/And/Or) + the 23-opcode taxonomy. Brain = the reversible scalar-int value transform per opcode; the register-name dict stays host-side. *Reversibility (`invert∘execute = id`) pinned as `*_roundtrip` exports.* *Four gates green:* 4/4 compile, 2100/2100 parity (incl. every round-trip, over i32 extremes), 1 echo-boundary LOSSLESS (23-opcode encoding, agda exit 0), 4/4 assail-clean. Evidence: `migrated/EVIDENCE-C2.adoc`. Surfaced 3 compiler facts: unary `~` codegen bug (workaround `-a-1`), arithmetic `>>` + no `>>>` (logical-shift-right emulated). NEXT: C2 wave 2.
| C6+C8 (fan-out) | DONE | Parallel deep wave (2026-06-05): two Sonnet agents migrated clusters C6 (combat/enemy) + C8 (device/network); parent re-verified + consolidated. *16 kernels* staged under `proposals/idaptik/migrated/` — C6: CombatFx, Detection, DifficultyScale, Distraction, DualAlert, HitboxGeom, PlayerHp, SecurityAi; C8: GlobalNetworkData, NetworkManager, SecurityRank, DeviceCaps, LaptopState, NetworkTransfer, PowerManager, CovertLink. *Four gates green (re-run by parent, not just agent-reported):* 16/16 compile, *34280/34280 parity* (C6 8185 + C8 26095, independent oracles), 7 echo-boundary LOSSLESS proofs, 16/16 assail-clean. Re-verification CAUGHT 3 PA-AFF-001 findings the agent reports missed (SecurityAi, SecurityRank, NetworkManager) — fixed with the established guard-helper clamp declaration; NetworkManager parity held at 2645/2645 after dropping the dead `Cat` enum, confirming semantics preserved. *4th compiler finding:* Float→wasm codegen broadly incomplete (pub-fn exports always type i32; float-literal operands mis-emit; `trunc()`/`float()` absent) — drives the `*Int.affine` parity subsets, keeps floats host-side. Evidence: `migrated/EVIDENCE-C6.adoc` + `EVIDENCE-C8.adoc`. NEXT: complete C7, then C2 wave 2.
| C7 | DONE | Player cluster complete (2026-06-05): two scoped agents (5 verify + 3 float-re-decompose) + parent re-verification. *8 kernels* — CriticalRoll, PlayerAttributes, QCertifications, SkillRank, SkillAbilities, QPrograms, JessicaLoadout, JessicaBackground. *Four gates green (every gate re-run by parent):* 8/8 compile, *4348/4348 parity* (independent oracles), *6 echo-boundary LOSSLESS proofs* across 4 kernels (QCertifications, SkillRank, JessicaLoadout×3, JessicaBackground — agda re-typechecked by parent, exit 0); the other 4 transform/classifier kernels G3-n/a, 8/8 assail-clean. 3 kernels (CriticalRoll/PlayerAttributes/QPrograms) hit the Float→wasm wall (`min_float`/`max_float`/`trunc`) and were re-decomposed Int-native (milli-unit convention, floats host-side) per the C6 `*Int` pattern; parity caught + fixed an off-by-100 in PlayerAttributes. Evidence: `migrated/EVIDENCE-C7.adoc`. NEXT: C2 wave 2.
| C2 wave 2a | DONE | Scalar multiply/divide (2026-06-05, Opus). `Mul`/`Div` turned out to be pure *scalar* value-transforms (not memory ops), so they need *no* array ABI — split out of "wave 2" and landed now. *1 kernel* `VmMulDiv` (11 exports) under `proposals/idaptik/migrated/`. Brain = the reversible ancilla multiply (`c := c + a*b`, inverse `c := c - a*b`) + the quotient/remainder divide whose dividend is reconstructable (`q*b + r == a` for all b incl. 0); the intentional-flaw in-place/simple variants migrated as value transforms with no rt==id claim. *Four gates green:* 1/1 compile, *3322/3322 parity* (incl. mul reversibility roundtrip + div reconstruction), G3 n/a (numeric transform), 1/1 assail-clean. *2 new i32 ABI facts:* (a) JS `a*b` loses precision >2^53 → oracle must use `Math.imul` to match `i32.mul`; (b) `i32.div_s` TRAPS on `b==0` and `INT_MIN/-1` ( wraps the latter) → brain guards both (nested-`if`, avoiding the unverified `&&` codegen path) so the wasm is total. Evidence: `migrated/EVIDENCE-C2-wave2a.adoc`. NEXT: C2 wave 2b.
| C2 wave 2b | DONE | Memory/stack/port/control opcodes (2026-06-05, Opus). *The "needs an array/linear-memory ABI" premise was WRONG and re-decomposition overturned it:* reading the sources, the VM's memory/stack/port buffers are not arrays in the brain — `VmState.res` stores every one as string-keyed dict slots (`_mem:N`, `_s:N`, `_pin:port`), i.e. host-side STATE (senses). The integer brains are all scalar. *4 kernels* `VmMemory` (LOAD/STORE), `VmStack` (PUSH/POP), `VmPort` (RECV/SEND), `VmControl` (IF_POS/IF_ZERO/LOOP) covering *9 opcodes*. *Four gates green:* 4/4 compile, *1568/1568 parity* (incl. load/store/sp/recv/send round-trips), G3 n/a (transforms/predicates), 4/4 assail-clean. No-kernel senses (classified, not migrated): Call (orchestration), CoprocessorCall (tombstone — never implemented), State/VmState/VM/SubroutineRegistry/VmStateCoprocessor/InstructionCoprocessor (state containers / bridges). Evidence: `migrated/EVIDENCE-C2-wave2b.adoc`. *No array ABI was required* (the blocker was a triage-bucket artefact). *Cluster C2 COMPLETE.* NEXT: C3.
| C3 | DONE | Coprocessor wiring layer (2026-06-05, Opus) — *classified, no new brains to extract.* C3's 17 `src/shared/` files are the host-side bridge layer that exposes C1's already-migrated wasm brains to the game engine. Verified de-dup (by constant, not name): the integer cores C3 needs (`compute_gate`/`data_limit_for_domain` 513/16/259/1024, `max_concurrent_compute` 10, `is_transient` 503/504/429, policy table, DeviceType/GameEvent ordinals) are *all* already migrated + 4-gate-verified in C1. The 12 `*Coprocessor.res` are pure-FFI passthrough (0 logic lines); `Kernel_Compute`/`RetryPolicy` (src/shared) are async/float wrappers over C1 brains; `DeviceType`/`GameEvent` add string-gated ops (string wall, Phase F). No 4-gate run (nothing new to verify). Bridges stay host-side as the essential wasm-loading glue (ADDITIVE, FeaturePacks-flagged); their →glue fate is a Phase-Ω cutover decision. Evidence: `migrated/EVIDENCE-C3.adoc`. NEXT: C5.
| C5 | DONE | Engine coprocessors (2026-06-05, Opus). *2 integer brains extracted + 9 senses classified.* `Maths` (clamp/lerp-milli/dist_sq — float-wall convention, sqrt host-side) and — the strategic one — `Random`, the deterministic **xmur3 + mulberry32 PRNG**, the multiplayer-reproducibility backbone (host iterates the seed string + does the final u32→[0,1) float div; brain owns the i32 mixing). *Four gates green:* 2/2 compile, *2108/2108 parity* (Random **bit-exact** vs the JS source over the full i32 domain + UTF-16 code units, incl. the `>>>` emulation + signed-i32 multiplier constants), G3 n/a (transforms/mixing), 2/2 assail-clean. Senses (classified, not migrated): 5 `*Coprocessor` FFI bridges, Resize/GetResolution (float+DOM), Audio (Pixi), Navigation (screen-stack orchestration + async asset load, effect-gated), WaitFor (timing). Evidence: `migrated/EVIDENCE-C5.adoc`. NEXT: C11 (done below).
| C11 | DONE | UI and accessibility coprocessors (2026-06-05) — *classified, NO_NEW_BRAINS.* All 10 files are host-side senses. `DualAlert.res` integer core (alert-level ordinals, escalation, trigger-milli, scrub, overall-threat, dispatch predicates) was already extracted in C6 (`DualAlert.affine`, 613/613 parity, LOSSLESS boundary proof). `DualAlertCoprocessor.res` is the existing  FFI bridge to that wasm. `ForceLayoutCoprocessor.res` wraps `forcelayout.wasm`, a float-only physics kernel (euclidean/repulsion/attraction/velocity/alpha — f64 throughout; float-wall: brain stays host-side). Remaining 7 files (`Button`, `HubButton`, `NavButton`, `Label`, `VolumeSlider`, `InventoryUI`, `HardwareWiring`) are pure PixiJS/PixiUI senses with zero separable integer computation. No 4-gate run. Evidence: `migrated/EVIDENCE-C11.adoc`. NEXT: C9.
| C9 | DONE | Game-loop / AffineTEA / VeriSim types (2026-06-05) — *classified, NO_NEW_BRAINS.* All 16 files are host-side senses. *4 bridge files* wrap already-live wasm brains: `AffineTEA.res` → `titlescreen.wasm` (TEA title-screen state machine), `AffineTEARouter.res` → `router.wasm` (affine nav back-stack), `PixiCoprocessor.res` → `pixi.wasm` (AffineScript PixiJS scene driver), `VmCoprocessor.res` → `vmcoprocessor.wasm` (C2). *8 VeriSimDB/Burble HTTP clients* (`SaveGame`, `VeriSimDrift`, `VeriSimFederation`, `VeriSimHexad`, `VeriSimProvenance`, `VeriSimSearch`, `VeriSimVcl`, `BurbleIntegration`) are async `fetch` wrappers — effect-gated throughout. `VeriSimTypes.res` enum variants (`modality`/`hexadStatus`/`driftLevel`) appear integer-clean but `modalityToString`/`modalityFromString` use string literals and all enums are used only in string-bearing records — string-wall gated, no standalone computation. `VeriSimError.res` constructors all carry `string` payloads — string-wall gated. `Main.res` / `GameLoop.res` are effect-gated orchestration; GameLoop integer counters are trivial increments interleaved with float physics and mutable JS objects. No 4-gate run. Evidence: `migrated/EVIDENCE-C9.adoc`. NEXT: C10.
| C10 | DONE | Utils/tools/companions/narrative/proven (2026-06-05) — *classified, NO_NEW_BRAINS.* All 21 files are host-side senses. *9 bridge files* wrap already-live wasm brains: `moletairecoprocessors.wasm` (Moletaire behaviour FSM), `moletairehunger.wasm` (hunger ordinals), `missionbriefing.wasm` (mission briefing state machine), `safefloat.wasm` (safe-float arithmetic), `passwordcracker.wasm` (password-crack score), `portscanner.wasm` (port-scan classifier), `colorpalette.wasm` (color lookup), `fontscale.wasm` (font-scale arithmetic), `gamei18n.wasm` (plural-form + language-cycle). Float-wall: `MoletaireHunger.res` (f64 gravity/hunger physics — integer enum functions already in `moletairehunger.wasm`), `SafeAngle.res` (Math.atan2/cos/sin/mod_float, all f64). String-wall: `ProvenError.res` (all constructors carry string fields), `MoletairePersistence.res` (equipmentToString/FromString), `Locales.res` (5-language translation dicts, ~650 string pairs), `PolyglotI18n.res` (String.replaceAll, async fetch of locale JSON). Effect-gated: `Announcer.res` (AccessibilitySettings + DomA11y), `DomA11y.res` (%raw DOM), `ColorPalette.res` (AccessibilitySettings mutable refs; color tables already in wasm), `KeyboardNav.res` (%raw window events), `PanicHandler.res` (Console.error), `UserSettings.res` (Storage/Audio/DesktopIntegration; stance enum string-serialized). No 4-gate run. Evidence: `migrated/EVIDENCE-C10.adoc`. NEXT: C12.
| C12 | DONE | Render-glue screens/PixiJS bindings (2026-06-05) — *classified, NO_NEW_BRAINS.* All 43 files are host-side senses. *12 bridge files* wrap already-live wasm brains (companionsrenderlogic, devicesrenderlogic, enemiesrenderlogic, toolspickupsrenderlogic, playerrenderlogic, narrativepopupsrenderlogic, balanceanalyser, locationdata, screenglitchfx, screensrunlooplogic, uirenderlogic, verisimprovenrenderlogic). *23 screen/popup files* are effect-gated PixiJS rendering orchestrators (GameI18n string calls, Motion.animate, mutable refs, Navigation APIs). *4 binding files* (Motion, Pixi, PixiSound, PixiUI) are pure `external` FFI declarations wrapping host-side JS libraries. *Phase Ω cluster sequence (C11→C9→C10→C12) complete.* Genuine compiler gates remain: string wall (71 corpus files — `[len:i32][utf8]` layout already in `codegen.ml:375`, only ops missing), effect wall (111 corpus files). Evidence: `migrated/EVIDENCE-C12.adoc`.
| F (string slice 1) | DONE | String-wall slice 1 — *read-side indexing landed in the wasm backend* (2026-06-12, Opus). `string_char_code_at(s, i)` + `char_to_int(c)` (already wired in resolve/typecheck/interp) gained wasm lowerings on the `[len: i32 LE][utf8]` ABI: byte `i` at `base + 4 + i`, bounds-guarded `If` load, `-1` OOB sentinel shared with the interp oracle; `char_to_int` is the i32 identity. A previously STRING-GATED shape (byte indexing into a literal) now *compiles to wasm and parity-greens* vs the interpreter. *Gates:* G1 builds; G2 parity 7/7 (interp oracle vs wasm: 65/66/67/-1/-1/-1/90); type safety preserved (`char_to_int(65)` still a Char/Int error). Tests: `test/test_e2e.ml` group "E2E String-wall slice 1 (indexing)" (interp, `dune runtest`) + `tests/codegen/string_char_code_at.{affine,…mjs}` (executable wasm parity, full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice1.adoc`. Unblocks the *indexing/scan* family; allocation-returning string ops (`string_from_char_code`/`string_sub`/`slice`/concat/case-fold) are later slices. NEXT: allocation-returning ABI (`gen_string_alloc` + `string_from_char_code`).
| F (string slice 2) | DONE | String-wall slice 2 — *write-side ABI landed in the wasm backend* (2026-06-12, Opus). `string_from_char_code(n)` (already wired in resolve/typecheck/interp) gained a wasm lowering: bump-allocate `[len: i32 LE = 1][byte]` where the byte is the low 8 bits of `n` (`I32Store8` does the `land 0xff` mask itself, incl. negatives). First heap-allocating string op; reuses the existing `gen_heap_alloc` bump allocator (no new memory machinery). *Gates:* G1 builds; G2 parity 8/8 (interp vs wasm over masking/NUL/255/256/-1/320/length/OOB-after-construction); round-trips with slice-1's reader. Tests: `test/test_e2e.ml` group "E2E String-wall slice 2 (string_from_char_code)" (interp, `dune runtest`) + `tests/codegen/string_from_char_code.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice2.adoc`. Read+write byte primitives now both present. NEXT: runtime-length allocation ABI for `string_sub`/`slice` (slice 3).
| F (string slice 3) | DONE | String-wall slice 3 — *runtime-length copy op landed in the wasm backend* (2026-06-12, Opus). `string_sub(s, start, length)` (already wired in resolve/typecheck/interp) gained a wasm lowering introducing the two capabilities the rest of the wall needs: a *runtime-sized* heap allocation (`heap += 4 + length'`) and a *byte-copy loop* (`Block`/`Loop`/`BrIf`/`Br`, `I32Load8U`/`I32Store8`), both modelled on the list `++` allocate-then-copy idiom. Interp clamps reproduced exactly: `start' = max 0 (min start slen)`, `length' = max 0 (min length (slen - start'))` (`min`/`max` via `Select`). *Gates:* G1 builds; G2 parity 8/8 (interp vs wasm over extraction/length/clamp-length/clamp-start/negative-start/zero-length); round-trips with slice-1's reader. Tests: `test/test_e2e.ml` group "E2E String-wall slice 3 (string_sub)" (interp, `dune runtest`) + `tests/codegen/string_sub.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice3.adoc`. Runtime alloc + copy loop now established. NEXT: concat/`string_find`/case-fold (slice 4) reuse these idioms.
| F (string slice 4) | DONE | String-wall slice 4 — *ASCII case-folding landed in the wasm backend* (2026-06-13, Opus). `to_lowercase` / `to_uppercase` (already wired in resolve/typecheck/interp) gained wasm lowerings as a *copy-with-transform* over the slice-3 runtime-length idiom: allocate `4 + slen`, copy each byte through a *branchless* ASCII case shift (`c ± 32*((c>=lo)&(c<=hi))`, an `I32And` 0/1 product — no per-byte `If`). Matches the interp oracle (`String.{lowercase,uppercase}_ascii`): only `'A'..'Z'`/`'a'..'z'` shift; digits, punctuation, and bytes ≥128 pass through. *Gates:* G1 builds; G2 parity 7/7 (interp vs wasm), incl. the exclusive `@`(64)/`[`(91) boundary probes + length preservation. Tests: `test/test_e2e.ml` group "E2E String-wall slice 4 (case-fold)" (interp, `dune runtest`) + `tests/codegen/string_case.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice4.adoc`. Proves the alloc+copy idiom carries per-byte transforms. NEXT: concat (`++`/`string_concat`), then `string_find`/`trim`/`slice`.
| F (string slice 5) | DONE | String-wall slice 5 — *whitespace trimming landed in the wasm backend* (2026-06-13, Opus). `trim` (already wired in resolve/typecheck/interp) gained a wasm lowering: two flat scans bracket the non-whitespace core (front for `lo`, back for `hi` exclusive; whitespace set `{32,9,10,12,13}` via an `is_ws` `I32Or` chain), then a slice-3-style byte copy of `[lo, hi)`. Matches `String.trim`: internal whitespace preserved, all-whitespace -> empty. *Gates:* G1 builds; G2 parity 7/7 (interp vs wasm: both-end/leading/trailing, all-ws, empty, internal-space preservation, tab+newline). Tests: `test/test_e2e.ml` group "E2E String-wall slice 5 (trim)" (interp, `dune runtest`) + `tests/codegen/string_trim.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice5.adoc`. *The clean name-dispatched builtins (read/write/copy/transform/scan) are now done.* NEXT decision point — see F+ row.
| F (string slice 6) | DONE | String-wall slice 6 — *substring search landed in the wasm backend + an interp bugfix* (2026-06-13, Opus). `string_find(haystack, needle)` (already wired in resolve/typecheck/interp) gained a wasm lowering: a *flat-safe* nested scan (inner AND-accumulates a match over `nlen` bytes with no early break; outer exits at top on `res != -1` or `i > hlen - nlen`; result update is a branchless `Select`) returning the first-occurrence index or -1, no allocation. *Also fixed a latent interp bug:* `string_find` called `String.get needle 0` before its `nlen = 0` guard, so an empty needle crashed the interpreter (uncaught `Invalid_argument`); the guard is reordered so an empty needle returns `0`, making both backends agree. *Gates:* G1 builds; G2 parity 8/8 (found/not-found/first-occurrence/needle-longer/empty-needle/empty-haystack/both-empty). Tests: `test/test_e2e.ml` group "E2E String-wall slice 6 (string_find)" (interp, incl. the formerly-crashing empty-needle case, `dune runtest`) + `tests/codegen/string_find.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice6.adoc`. *All name-dispatched string builtins with a trap-free wasm analogue are now lowered.* NEXT: `int_to_string` (last clean op), then the gated concat/`slice` decision.
| F (string slice 7) | DONE | String-wall slice 7 — *decimal rendering landed in the wasm backend* (2026-06-13, Opus). `int_to_string(n)` (already wired in resolve/typecheck/interp) gained a wasm lowering over the slice-3 allocation idiom: `neg = n<0`; `m = neg ? n : -n` (non-positive magnitude via `Select`); a do-while digit-count loop (so `n=0` yields one '0' with no special case); allocate `4 + dc + neg`; a do-while backward-write loop emitting `48 - (m%10)` per digit; leading `'-'` when negative. *INT_MIN-safe* — the un-representable positive magnitude is never formed; `div_s`/`rem_s` by 10 never trap. *Gates:* G1 builds; G2 parity over 0/single/multi-digit/negative/powers-of-ten/INT_MAX/INT_MIN (wasm string read back from memory vs `string_of_int`). Tests: `test/test_e2e.ml` new `eval_string_fn` + group "E2E String-wall slice 7 (int_to_string)" (5 full-string cases incl. INT_MIN, `dune runtest`) + `tests/codegen/string_int_to_string.{affine,…mjs}` (executable wasm parity; full `run_codegen_wasm_tests.sh` green, no regressions). Evidence: `proposals/EVIDENCE-stringwall-slice7.adoc`. *Clean lane CLOSED — every name-dispatched string builtin with a trap-free wasm analogue is lowered.* NEXT: the gated concat/`slice` decision, or the effect wall.
| F (string slice 8a) | DONE | String-wall slice 8a — *string-`++` footgun GUARD landed in the wasm backend* (2026-06-13, Opus). *Finding:* string `++` silently miscompiles to wasm — it typechecks (`++` is type-dispatched at `typecheck.ml:1062`), the interp is correct, but codegen lowers `++` only as *list*-concat (4-byte element stride), so a string's `[len][utf8]` is copied as i32 elements (`"ab" ++ "cd"` -> byte 2 = 2, the length word of "cd", instead of 'c'). A #555-class silent mis-lowering. *Decision (owner, "guard first, then full fix"):* the "add a `string_concat` builtin / ban `++`" route is ruled out — `++` is the canonical, stdlib-pervasive surface and `string_concat` was deliberately removed (#330). *This slice:* the wasm `OpConcat` handler now rejects the syntactically-obvious string `++` (string literal / String-returning builtin call / nested string `++`, via `is_string_concat_operand`) with a loud `UnsupportedFeature` error instead of emitting garbage — a *sound-rejection* heuristic (never misfires on list `++`; deliberately incomplete on pure var-var string `++`, which slice 8b closes). *Gates:* G1 builds; guard verified (string `++` -> loud error; list `++` + slices 1-7 fixtures still compile; full `run_codegen_wasm_tests.sh` green incl. `list_concat`). Tests: `test/test_e2e.ml` group "E2E String-wall slice 8 guard". Design: `proposals/DESIGN-string-concat.adoc` (merged #574). NEXT: slice 8b — type-directed string-`++` lowering (`ExprStringConcat` elaboration + byte-concat), making `++` correct + complete.
| F (string slice 8b) | DONE | String-wall slice 8b — *type-directed string-`++` lowering landed in the wasm backend* (2026-06-13, Opus). The full fix the 8a guard stood in for. *Channel:* `synth` records each `++` it types as String concat (physical identity, `string_concat_sites`); `Typecheck.elaborate_string_concat` rewrites exactly those nodes to a new `ExprStringConcat` (`ast.ml`); the wasm path (`bin/main.ml`) runs it after typecheck, before `Opt.fold` — the interp + non-wasm backends keep the original `ExprBinary _ OpConcat _`, so the oracle is unchanged. Physical-identity keying is sound because typecheck and codegen share one `prog` object (`parse_with_face`'s lowered prog). *Lowering* (`codegen.ml`): byte concat — alloc `4 + la + lb`, length word, copy a's then b's bytes (the list-concat handler with 1-byte elements + one length word). *Effect parity:* `effect_sites.ml` recurses `ExprStringConcat` like `ExprBinary`, NOT as an `ExprApp` call site, so effect-ordinals stay identical interp-vs-wasm (no #555-class desync). `opt.ml` folds sub-exprs; `interp.ml` handles it defensively. *Gates:* G1 builds; the canonical miscompile is fixed (`"ab" ++ "cd"` byte 2 = 99 'c', was 2); var-var + chained + empty all parity-green; list `++` + slices 1-7 + effect tests still green (full `run_codegen_wasm_tests.sh`). Tests: `tests/codegen/string_concat.{affine,mjs}` (executable parity) + `test/test_e2e.ml` "E2E String-wall slice 8 guard" (incl. lowers-after-elaboration). The 8a guard is retained as a backstop. Design: `proposals/DESIGN-string-concat.adoc`. *String wall: every name-dispatched op (slices 1-7) + concat (8) now lower to wasm.*
| F+ | TODO | *String-wall residuals (minor/deferred), then the effect wall.* String residuals: *`slice`*-on-string negative-index (mostly covered by the shipped `string_sub`); *`float_to_string`* stays host-side permanently (float-wall — floats are Int-native-re-decomposed per the C6/C7 milli-unit convention); *`string_get`/`int_to_char`* deferred (raise on out-of-range; no trap-free wasm analogue). These do not gate "CLEAR" materially. *The big remaining compiler half is the effect-codegen wall* (111 EFFECT-GATED corpus files per Phase B): module-state, `Date.now`, `Console.log` → wasm. Essentially unstarted. After both walls + the migratable corpus: *Phase Ω* cutover/extinction (retire shadows, flip FeaturePacks). *String slices 1-8 landed (clean lane + concat).*
| Ω | TODO (access-gated) | Cutover +  extinction.
|===

*Resume rule:* read the highest non-DONE row + `migration-map.json`;
continue from its "Notes".

[IMPORTANT]
====
*Completion-drive directive (user-authorised 2026-06-05).* The owner authorised
driving the migratable corpus to completion *autonomously* — do NOT stop to
re-ask at each cluster boundary. Standing plan for the resuming agent:

. Drive the MIGRATABLE-NOW clusters in this order, each via the full 4-gate
  recipe, staged under `proposals/idaptik/migrated/`, one PR per cluster,
  squash-merged when the gates are green (CI on this repo is non-gating; the
  authoritative gates are the parity/assail/agda runs done locally):
  *C5 → C11 → C9 → C10 → C12.* Classify senses/string-gated/effect-gated files
  honestly (like C3) rather than forcing a migration.
. Then attack the two compiler walls *in this repo* (in scope): the string
  backend, then effect codegen — each unblocks its STRING-/EFFECT-GATED kernels.
. Update this Ledger + `migration-map.json` after every cluster (repo-as-memory;
  compaction-proof).

*Git hygiene (learned 2026-06-05):* base each cluster's work on the *dev-branch
tip* (`origin/claude/cool-keller-gr5sl`), NOT on `origin/main`'s squash SHA —
the remote dev branch is force-push-protected, so basing on it keeps pushes
fast-forward and avoids the post-squash-merge divergence that nags the
commit-identity hook. Rebase onto `origin/main` only if main has advanced beyond
the staged content.

*The one gate I cannot clear myself:* Phase Ω cutover (apply staged units to
idaptik + delete the `.res` shadows) needs idaptik *write-access*. Until the
owner opens that, "migrated" = "staged + 4-gate-verified", and true CLEAR
(→0) waits on access. Everything up to that line is drivable here.
====
