# Embedding ABI — M3.1 — Implementation Plan > **Parent spec:** `docs/specs/2026-05-18-embedding-abi-m3.md` > > **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` > to run this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Let a single-constructor record of `Int`/`Float` fields cross the embedding C boundary in and out, ownership following the declared `own`/`borrow` mode, with the record memory layout frozen (byte-pinned) and the alloc==free contract proven for both modes. **Architecture:** Three layers change. (1) `ailang-check` — the nested export-gate predicate `is_c_scalar` becomes `is_c_abi_type`: accept `Int`/`Float` **or** a `Type::Con` resolving (via the in-scope `env.types`) to a single-constructor `data` whose every field is a bare `Int`/`Float` (two-level, NOT deep — a record-typed field stays rejected = M4). (2) `ailang-codegen` — `fn_scalar_sig`/`llvm_scalar` widen so a record type maps to `ptr`; the M2 `Target::StaticLib` forwarder body is byte-unchanged (a record is a bare `ptr`; all mode-driven RC stays in the byte-unchanged internal `@ail__`). (3) docs+pins — DESIGN.md §"Embedding ABI" gains a frozen value-layout SSOT, "provisional until M3" becomes one-way freeze, and the heap box layout codegen emits is byte-pinned with lockstep comment pointers from the three encoding sites. **Tech Stack:** `crates/ailang-check` (gate), `crates/ailang-codegen` (forwarder + box-layout sites), `runtime/rc.c` (layout comment), `docs/DESIGN.md` (frozen SSOT), `examples/*.ail` (fixtures), `crates/ail/tests` (E2E C-host harness), `crates/ailang-codegen/tests` (byte-pin + forwarder-IR pin). --- ## Files this plan creates or modifies - Test: `crates/ailang-check/tests/embed_export_gate.rs:31-34` — `adt_ret_export_rejected` (the deliberate re-point target; baseline-confirmed Task 1, re-pointed Task 2) - Create: `crates/ailang-codegen/tests/embed_record_layout_pin.rs` — heap-box byte-pin (`@ailang_rc_alloc(i64 24)` + tag@0 + field offsets) for a single-ctor 2-scalar-field record (Task 1) - Create: `examples/embed_record_layout_carrier.ail` — minimal today-valid non-export program that heap-allocates a single-ctor `(Int,Int)` record, the byte-pin carrier (Task 1) - Modify: `crates/ailang-check/src/lib.rs:1921-1924` — `is_c_scalar` → `is_c_abi_type` (Task 2) - Modify: `crates/ailang-check/src/lib.rs:1925-1940` — gate param/ret loop calls `is_c_abi_type(ty, env)` (Task 2) - Modify: `crates/ailang-check/src/lib.rs:452` / `:458` — widen the two `#[error(...)]` message strings (Task 2) - Modify: `examples/embed_export_adt_ret_rejected.ail` — content replacement: single-ctor `Pair` → multi-ctor + `Str`-field `Reading` (Task 2) - Create: `examples/embed_export_str_field_record_rejected.ail` — single-ctor record, one `Str` field (Task 2) - Create: `examples/embed_export_list_field_record_rejected.ail` — single-ctor record, one `List` field (Task 2) - Create: `examples/embed_export_multictor_rejected.ail` — 2-ctor all-scalar sum (Task 2) - Create: `examples/embed_export_record_ok.ail` — single-ctor `(Float,Int)` record export, the positive (Task 2) - Test: `crates/ailang-check/tests/embed_export_gate.rs` — add 4 RED + 1 positive test fns (Task 2) - Modify: `crates/ailang-codegen/src/lib.rs:659-669` — `fn_scalar_sig` accepts a record param/ret (Task 3) - Modify: `crates/ailang-codegen/src/lib.rs:673-678` — `llvm_scalar` maps a record `Type::Con` → `"ptr"` (Task 3) - Create: `examples/embed_backtest_step_record.ail` — north-star `(own (con State))` `(State,Float)->State` (Task 3) - Create: `examples/embed_backtest_step_record_borrow.ail` — `(borrow (con State))` variant (Task 3) - Test: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs` — add `staticlib_record_forwarder_is_ptr_passthrough` (Task 3) - Create: `crates/ail/tests/embed/record_roundtrip.c` — the C host (own + borrow, compile-time `MODE` switch) (Task 4/5) - Create: `crates/ail/tests/embed_record_e2e.rs` — builds libs, links the C host, runs, asserts `allocs==frees` + value (Task 4/5) - Modify: `docs/DESIGN.md:2280-2282` / `:2293-2295` — "provisional until M3" → one-way-freeze wording (Task 6) - Modify: `docs/DESIGN.md:2317` — insert the frozen value-layout SSOT subsection before `## Data model` (`:2319`) (Task 6) - Modify: `runtime/rc.c:28-44` — add the lockstep-pointer line to the layout comment (Task 6) - Modify: `crates/ailang-codegen/src/match_lower.rs:107` / `:123-126` / `:128-136` — add `// FROZEN ABI` lockstep comment at the size/tag/field encoders (Task 6) - Modify: `crates/ailang-codegen/src/drop.rs:88` / `:113` — add `// FROZEN ABI` lockstep comment at the tag-load / field-offset encoders (Task 6) - Modify: `crates/ailang-codegen/src/lib.rs:178` — rustdoc "provisional until M3" → frozen (Task 6) - Modify: `crates/ailang-core/src/ast.rs:211` — rustdoc "provisional until M3" → frozen (Task 6) --- ## Task 1: Prerequisite baseline pins (FIXED FIRST — spec §"Iteration sequencing") Both load-bearing M3 changes (the gate widen and the layout freeze) must rest on a baseline pinned at their own layer *before* the change (spec §"Iteration sequencing"; 2026-05-11 failure class pre-empted). This task introduces **no** behaviour change — only baselines. **Files:** - Test: `crates/ailang-check/tests/embed_export_gate.rs:31-34` - Create: `examples/embed_record_layout_carrier.ail` - Create: `crates/ailang-codegen/tests/embed_record_layout_pin.rs` - [ ] **Step 1: Confirm the re-point baseline pin is green TODAY** The pin M3 deliberately re-points already exists (`adt_ret_export_rejected`, `crates/ailang-check/tests/embed_export_gate.rs:31-34`, asserting `examples/embed_export_adt_ret_rejected.ail` yields code `"export-non-scalar-signature"`). Confirm it is green on the current tree as the baseline. Run: `cargo test -p ailang-check --test embed_export_gate adt_ret_export_rejected -- --exact` Expected: PASS, `1 passed` (it asserts the single-ctor `Pair` ADT return is rejected *today* — the M1 "any ADT rejected" meaning). - [ ] **Step 2: Annotate the pin as the deliberate re-point target** In `crates/ailang-check/tests/embed_export_gate.rs`, immediately above the `adt_ret_export_rejected` test fn (`:31`), add this comment so Task 2's change to it is reviewed-explicit, not a silent inversion (spec §"Testing strategy" item 0(a)): ```rust // M3 RE-POINT (deliberate, reviewed — spec 2026-05-18-embedding-abi-m3 // §"must-fail axis"): the fixture this asserts on // (embed_export_adt_ret_rejected.ail) is single-ctor two-Int = M3-VALID. // Task 2 re-points the fixture to a genuinely-still-rejected ADT // (multi-ctor + Str field). This pin's *meaning* changes from // "any ADT rejected" to "non-M3-shaped ADT rejected"; it is NOT // silently inverted — the rejection (export-non-scalar-signature) // stays asserted, on a fixture that still genuinely fails it. ``` - [ ] **Step 3: Run the gate suite to confirm the comment is inert** Run: `cargo test -p ailang-check --test embed_export_gate` Expected: PASS, `6 passed` (comment-only change; the existing six tests `str_param_export_rejected`, `adt_ret_export_rejected`, `io_export_rejected`, `combined_effectful_export_rejected_on_signature_first`, `scalar_pure_export_passes`, `headline_int_export_passes` all green). - [ ] **Step 4: Write the byte-pin carrier fixture** Create `examples/embed_record_layout_carrier.ail` — a today-valid (pre-M3, no `(export)`) program whose `main` heap-allocates a single-ctor two-`Int` record so the standard escaping `lower_ctor` heap path (`@ailang_rc_alloc`, the path a crossing M3 record takes) emits its box. Grounded against `examples/borrow_own_demo.ail` (`(data … (ctor …))`, `(match … (case (pat-ctor …) …))`, `print`): ``` (module embed_record_layout_carrier (data Pt (ctor Pt (con Int) (con Int))) (fn mk (type (fn-type (params (con Int) (con Int)) (ret (con Pt)))) (params a b) (body (app Pt a b))) (fn sum_pt (type (fn-type (params (own (con Pt))) (ret (con Int)))) (params p) (body (match p (case (pat-ctor Pt x y) (app + x y))))) (fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (app print (app sum_pt (app mk 3 4)))))) ``` - [ ] **Step 5: Verify the carrier compiles and emits the heap box TODAY** Run: `cargo run -q --bin ail -- check examples/embed_record_layout_carrier.ail` Expected: exit 0, no diagnostics. Run: `cargo run -q --bin ail -- emit-ir examples/embed_record_layout_carrier.ail -o /tmp/m3_carrier.ll && grep -nE 'ailang_rc_alloc\(i64 24\)|store i64 0, ptr|getelementptr inbounds i8, ptr .* i64 8|getelementptr inbounds i8, ptr .* i64 16' /tmp/m3_carrier.ll` Expected: matches present — `call ptr @ailang_rc_alloc(i64 24)` (size = 8 header-excluded payload? — confirm: `match_lower.rs:107` `size_bytes = 8 + n*8`; for n=2 that is `8 + 16 = 24`), a `store i64 0, ptr` (tag at offset 0), and the field GEPs at i64 8 / i64 16. (If the heap path is not taken — escape analysis kept it in `alloca` — adjust the carrier so `Pt` provably escapes: returning it from `mk` and consuming via `own`-param `sum_pt` forces the heap box; this is why `mk`/`sum_pt` are split. Confirm the `@ailang_rc_alloc` form is what appears; the byte-pin asserts that exact form.) - [ ] **Step 6: Write the heap-box byte-pin test** Create `crates/ailang-codegen/tests/embed_record_layout_pin.rs`, modelled exactly on the harness shape of `crates/ailang-codegen/tests/embed_staticlib_lowering.rs` (`lower_workspace`/`emit-ir` → `ir.contains(...)`; open that file and mirror its workspace-load + lower incantation verbatim, substituting the carrier fixture). Assert the **frozen heap layout as codegen emits it today**: ```rust //! FROZEN ABI byte-pin — see DESIGN.md §"Embedding ABI" frozen layout. //! Pins the heap (@ailang_rc_alloc) box layout a boundary-crossing //! single-ctor scalar record takes: size = 8 + n*8, tag i64 @ offset //! 0, fields i64-strided from offset 8. Goes RED if codegen moves an //! offset — the M3 freeze made enforceable, not aspirational. // #[test] fn heap_box_layout_is_frozen_8_plus_n_times_8_tag_at_0_fields_from_8() { let ir = lower_carrier_ir(); // the mirrored helper // Pt has 2 Int fields → payload size 8 (tag) + 2*8 (fields) = 24 assert!(ir.contains("@ailang_rc_alloc(i64 24)"), "frozen: heap box payload size = 8 + n*8 (n=2 → 24); IR:\n{ir}"); // constructor tag written at offset 0 (single-ctor → 0; no elision) assert!(ir.contains("store i64 0, ptr"), "frozen: ctor tag i64 at offset 0; IR:\n{ir}"); // field 0 at offset 8, field 1 at offset 16 (i64-strided from 8) assert!(ir.contains("getelementptr inbounds i8, ptr") && ir.contains("i64 8"), "frozen: field 0 at payload offset 8; IR:\n{ir}"); assert!(ir.contains("i64 16"), "frozen: field 1 at payload offset 16 (8 + 1*8); IR:\n{ir}"); } ``` (Exact `contains` substrings must be tightened to what `/tmp/m3_carrier.ll` actually shows in Step 5 — copy the literal emitted forms; do not assert a guessed shape. The four asserted facts — size 24, tag@0, field@8, field@16 — are the frozen layout.) - [ ] **Step 7: Run the byte-pin GREEN on the current (pre-M3) tree** Run: `cargo test -p ailang-codegen --test embed_record_layout_pin -- --exact heap_box_layout_is_frozen_8_plus_n_times_8_tag_at_0_fields_from_8` Expected: PASS, `1 passed` — the layout baseline is pinned *before* any M3 change attaches the freeze lockstep (spec §"Testing strategy" item 0(b)). --- ## Task 2: Widen the export gate to a single-ctor scalar record `is_c_scalar` (a nested local fn in `check_fn`, one caller — the gate itself; recon-confirmed no other caller, fully contained) becomes `is_c_abi_type`, **two-level**: a bare `Int`/`Float`, OR a `Type::Con` resolving via `env.types` to a single-constructor `data` whose every field type is a bare `Int`/`Float`. A record-typed field stays rejected (M4 — spec §"Out of scope"). No new `CheckError` variant; the two `#[error]` message strings widen (pin-safe — the gate test asserts the code string, not the message). **Files:** - Modify: `crates/ailang-check/src/lib.rs:1921-1924` (predicate) - Modify: `crates/ailang-check/src/lib.rs:1925-1940` (gate loop call site) - Modify: `crates/ailang-check/src/lib.rs:452` / `:458` (messages) - Modify: `examples/embed_export_adt_ret_rejected.ail` (re-point) - Create: 4 must-fail fixtures + 1 positive fixture (see file list) - Test: `crates/ailang-check/tests/embed_export_gate.rs` - [ ] **Step 1: Write the failing tests (RED) — gate fixtures** Create the five fixtures. `examples/embed_export_record_ok.ail` (positive — must PASS check): ``` (module embed_export_record_ok (data State (ctor State (con Float) (con Int))) (fn step (export "step") (type (fn-type (params (own (con State)) (con Float)) (ret (con State)))) (params st sample) (body (match st (case (pat-ctor State acc n) (app State (app + acc sample) (app + n 1))))))) ``` `examples/embed_export_str_field_record_rejected.ail` (single-ctor, one `Str` field — must FAIL): ``` (module embed_export_str_field_record_rejected (data Tagged (ctor Tagged (con Int) (con Str))) (fn f (export "f") (type (fn-type (params (con Int)) (ret (con Tagged)))) (params n) (body (app Tagged n "x")))) ``` `examples/embed_export_list_field_record_rejected.ail` (single-ctor, one `List` field — must FAIL; `List` def grounded on `examples/borrow_own_demo.ail`): ``` (module embed_export_list_field_record_rejected (data List (ctor Nil) (ctor Cons (con Int) (con List))) (data Boxed (ctor Boxed (con Int) (con List))) (fn f (export "f") (type (fn-type (params (con Int)) (ret (con Boxed)))) (params n) (body (app Boxed n (term-ctor List Nil))))) ``` (Note: `List` here is itself multi-ctor, so the `Boxed` rejection reason that bites is the **non-scalar field** rule — `Boxed` is single-ctor but its field `List` is not a bare `Int`/`Float`. That is exactly the M4-boundary case the spec wants pinned.) `examples/embed_export_multictor_rejected.ail` (2-ctor all-scalar sum — must FAIL on the single-ctor rule): ``` (module embed_export_multictor_rejected (data Either2 (ctor L (con Int)) (ctor R (con Float))) (fn f (export "f") (type (fn-type (params (con Int)) (ret (con Either2)))) (params n) (body (app L n)))) ``` Re-point `examples/embed_export_adt_ret_rejected.ail` — **replace its entire content** (was single-ctor `Pair`, now M3-valid) with the multi-ctor + `Str`-field `Reading` from spec §"must-fail axis" (module name stays `embed_export_adt_ret_rejected` = file-stem, the loader invariant): ``` (module embed_export_adt_ret_rejected (data Reading (ctor Missing) (ctor Sample (con Int) (con Str))) (fn f (export "f") (type (fn-type (params (con Int)) (ret (con Reading)))) (params n) (body (app Sample n "tick")))) ``` In `crates/ailang-check/tests/embed_export_gate.rs`, add (mirroring the existing `check_codes("…")`/assert pattern at `:25-63` — open the file and copy that helper's exact form): ```rust #[test] fn record_export_passes() { // single-ctor (Float,Int) record export — M3-valid assert!(check_codes("embed_export_record_ok.ail").is_empty()); } #[test] fn str_field_record_export_rejected() { assert!(check_codes("embed_export_str_field_record_rejected.ail") .contains(&"export-non-scalar-signature".to_string())); } #[test] fn list_field_record_export_rejected() { assert!(check_codes("embed_export_list_field_record_rejected.ail") .contains(&"export-non-scalar-signature".to_string())); } #[test] fn multictor_export_rejected() { assert!(check_codes("embed_export_multictor_rejected.ail") .contains(&"export-non-scalar-signature".to_string())); } ``` (`adt_ret_export_rejected` at `:31-34` is **kept as-is in assertion** — it still asserts `"export-non-scalar-signature"` on `embed_export_adt_ret_rejected.ail`; only the fixture content changed in this task. Its Task-1 re-point comment now describes a completed, reviewed change.) - [ ] **Step 2: Run the new tests to verify RED** Run: `cargo test -p ailang-check --test embed_export_gate record_export_passes str_field_record_export_rejected list_field_record_export_rejected multictor_export_rejected` Expected: FAIL — `record_export_passes` fails (today's `is_c_scalar` rejects the single-ctor `State` return, so `check_codes` is non-empty); the three `_rejected` tests *may* already pass (today everything ADT is rejected) — that is acceptable RED-state for the positive; the discriminating RED is `record_export_passes` failing and `adt_ret_export_rejected` will (after re-point) still pass. Run: `cargo test -p ailang-check --test embed_export_gate adt_ret_export_rejected -- --exact` Expected: PASS — the re-pointed `Reading` (2 ctors + `Str` field) still yields `export-non-scalar-signature` under *today's* gate (every non-scalar ADT is rejected pre-M3), so the re-point keeps the pin green through the transition (its meaning sharpens in Step 3). - [ ] **Step 3: Widen the predicate (`is_c_scalar` → `is_c_abi_type`)** In `crates/ailang-check/src/lib.rs`, replace the nested fn at `:1921-1924`: ```rust fn is_c_scalar(t: &Type) -> bool { matches!(t, Type::Con { name, args, .. } if args.is_empty() && (name == "Int" || name == "Float")) } ``` with the two-level M3 predicate (NOT deep — a record-typed field is rejected; M4 owns nesting): ```rust fn is_c_scalar(t: &Type) -> bool { matches!(t, Type::Con { name, args, .. } if args.is_empty() && (name == "Int" || name == "Float")) } // M3 (spec 2026-05-18-embedding-abi-m3 §Architecture L85-102): // a C scalar, OR a single-constructor `data` whose every field // is a *bare* C scalar. Two-level by design — a record-typed // field stays rejected (M4 owns nested-record recursion). Uses // the in-scope `env.types: String -> TypeDef` (read pattern as // `env.types.get(name)`, cf. lib.rs:1826). fn is_c_abi_type(t: &Type, env: &Env) -> bool { if is_c_scalar(t) { return true; } if let Type::Con { name, args, .. } = t { if args.is_empty() { if let Some(td) = env.types.get(name) { if td.ctors.len() == 1 { return td.ctors[0] .fields .iter() .all(is_c_scalar); } } } } false } ``` (`env`, `Type`, `Env`, `TypeDef.ctors[i].fields` are recon-confirmed in scope at the gate site `:1920` inside `check_fn`. If the field accessor differs — `ctor.fields` vs `ctor.field_types` — use the exact name the `drop.rs:100-102` walker uses; open `drop.rs` to confirm the accessor before writing this, then match it.) - [ ] **Step 4: Point the gate loop at `is_c_abi_type`** In `crates/ailang-check/src/lib.rs:1925-1940`, the param loop and ret check currently call `is_c_scalar(ty)`. Change each call site to `is_c_abi_type(ty, env)` (the `env` binding is the same one `is_c_abi_type` borrows; recon-confirmed in scope). Do **not** change the loop structure, the position `&'static str`, or the `ExportNonScalarSignature(...)` construction shape — only the predicate call. The effect check (`:1941-1946`) is untouched. - [ ] **Step 5: Widen the two `#[error]` message strings** `crates/ailang-check/src/lib.rs:452` currently: ```rust #[error("export `{0}`: M1 embedding ABI is scalar-only — {1} type `{2}` is not `Int`/`Float`")] ``` Replace with (single contiguous line — pin-safe; no test asserts this message text, only the `code()` string; recon-confirmed): ```rust #[error("export `{0}`: embedding ABI accepts `Int`/`Float` or a single-constructor record of those — {1} type `{2}` is not permitted (multi-constructor sums, and `Str`/`List`/nested-record fields, are a future M4 layer)")] ``` `crates/ailang-check/src/lib.rs:458` (the `ExportHasEffects` message) — only widen if it says "M1"; if it reads `export \`{0}\` must be pure …` leave it byte-unchanged (effects rule did not change). Open `:455-459`, and if `:458` contains the literal `M1`, drop the `M1 ` token only; otherwise no edit. - [ ] **Step 6: Run the gate suite to verify GREEN** Run: `cargo test -p ailang-check --test embed_export_gate` Expected: PASS, `10 passed` — the original 6 (incl. `adt_ret_export_rejected`, now sharpened: `Reading` fails on multi-ctor AND Str-field) + the 4 new (`record_export_passes`, `str_field_record_export_rejected`, `list_field_record_export_rejected`, `multictor_export_rejected`). - [ ] **Step 7: Confirm no `ailang-check` regression** Run: `cargo test -p ailang-check` Expected: PASS — workspace `ailang-check` suite green; the only behaviour change is the export gate widening (single-ctor scalar records now accepted; everything else byte-identical). --- ## Task 3: Widen the codegen forwarder to pass a record as `ptr` `fn_scalar_sig`/`llvm_scalar` widen so a record `Type::Con` maps to `ptr`; the M2 forwarder body (`:604-651`) is **byte-unchanged** (it already templates `define {cret} @sym(ptr %ctx, …)`; the change is only what `{cret}`/param types resolve to). All RC stays in the byte-unchanged internal `@ail__`. **Files:** - Modify: `crates/ailang-codegen/src/lib.rs:659-669` (`fn_scalar_sig`) - Modify: `crates/ailang-codegen/src/lib.rs:673-678` (`llvm_scalar`) - Create: `examples/embed_backtest_step_record.ail` - Create: `examples/embed_backtest_step_record_borrow.ail` - Test: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs` - [ ] **Step 1: Confirm the caller set of `fn_scalar_sig`/`llvm_scalar`** Run: `grep -rn 'fn_scalar_sig\|llvm_scalar' crates/ailang-codegen/src/` Expected: callers are only inside the `Target::StaticLib` forwarder block (`lib.rs:622`/`:628` per recon). Record every call site found; all must be threaded inside THIS task (no signature change is introduced — these fns keep their signatures — so no compile-gate / deferred-caller hazard; this step is the explicit confirmation that the change is contained). - [ ] **Step 2: Write the north-star + borrow fixtures** Create `examples/embed_backtest_step_record.ail` (spec headline, verbatim): ``` (module backtest (data State (ctor State (con Float) (con Int))) (fn step (export "backtest_step") (type (fn-type (params (own (con State)) (con Float)) (ret (con State)))) (params st sample) (body (match st (case (pat-ctor State acc n) (app State (app + acc sample) (app + n 1))))))) ``` Create `examples/embed_backtest_step_record_borrow.ail` — identical except the param mode: ``` (module backtest (data State (ctor State (con Float) (con Int))) (fn step (export "backtest_step") (type (fn-type (params (borrow (con State)) (con Float)) (ret (con State)))) (params st sample) (body (match st (case (pat-ctor State acc n) (app State (app + acc sample) (app + n 1))))))) ``` - [ ] **Step 3: Write the failing forwarder-IR pin (RED)** In `crates/ailang-codegen/tests/embed_staticlib_lowering.rs`, add (mirror the exact load+lower helper `staticlib_emits_forwarder_no_main` at `:20-44` uses — open the file, copy its incantation, substitute the record fixture): ```rust #[test] fn staticlib_record_forwarder_is_ptr_passthrough() { let ir = lower_staticlib_ir("embed_backtest_step_record.ail"); // mirror :20 // record param + ret cross as bare ptr; ctx leading; M2 TLS shape assert!(ir.contains("define ptr @backtest_step(ptr %ctx, ptr %a0, double %a1)"), "record fwd: ptr ret, leading ptr %ctx, ptr record param, double sample; IR:\n{ir}"); assert!(ir.contains("store ptr %ctx, ptr @__ail_tls_ctx"), "M2 TLS store byte-unchanged; IR:\n{ir}"); assert!(ir.contains("call ptr @ail_backtest_step(ptr %a0, double %a1)"), "internal call byte-unchanged (no RC at forwarder); IR:\n{ir}"); assert!(!ir.contains("sret") && !ir.contains("byval"), "no aggregate convention introduced — record is a bare ptr; IR:\n{ir}"); assert!(!ir.contains("define i32 @main()"), "staticlib: no @main; IR:\n{ir}"); } ``` (The exact param SSA names — `%a0`/`%a1` — and the `double` vs `i64` for the `(con Float) sample` must match what the forwarder template at `lib.rs:632-646` actually emits; after Step 4, read the emitted IR and tighten these `contains` to the literal emitted forms. `llvm_scalar` already maps `(con Float)` → `double`, recon-confirmed.) - [ ] **Step 4: Run the pin to verify RED** Run: `cargo build -p ailang-codegen 2>&1 | tail -3 ; cargo test -p ailang-codegen --test embed_staticlib_lowering staticlib_record_forwarder_is_ptr_passthrough -- --exact` Expected: FAIL — today `fn_scalar_sig` only matches scalar `Type::Fn`; `llvm_scalar` maps any non-`Float` `Type::Con` to `i64`, so a `(con State)` param/ret is mis-lowered to `i64` (or the workspace fails to lower the record export at all). The assertion `define ptr @backtest_step(ptr %ctx, ptr %a0, double %a1)` is unmet. - [ ] **Step 5: Widen `llvm_scalar`** `crates/ailang-codegen/src/lib.rs:673-678` currently: ```rust fn llvm_scalar(t: &Type) -> &'static str { match t { Type::Con { name, .. } if name == "Float" => "double", _ => "i64", } } ``` The forwarder must map a record (`Type::Con` resolving to a single-ctor `data`) to `"ptr"`, `Float` → `"double"`, `Int`/scalar → `"i64"`. `llvm_scalar` has no `env` param today. Rather than thread `env` through (signature change → caller ripple), distinguish structurally: at the forwarder, a non-`Int`/`Float` `Type::Con` that reached codegen on an `(export)` fn is — by Task-2's gate — a guaranteed single-ctor scalar record. So: ```rust fn llvm_scalar(t: &Type) -> &'static str { match t { Type::Con { name, .. } if name == "Float" => "double", Type::Con { name, .. } if name == "Int" => "i64", // M3: any other Type::Con reaching an (export) signature is, // by the Task-2 export gate, a single-ctor scalar record; // it crosses the C ABI as a bare `ptr` (DESIGN.md §"Embedding // ABI" frozen layout — payload pointer). Type::Con { .. } => "ptr", _ => "i64", } } ``` (The original `_ => "i64"` default for `Int` is preserved by the explicit `name == "Int"` arm; the new `Type::Con { .. } => "ptr"` only catches named-type records — exactly the gate-guaranteed M3 case. Confirm no scalar path relies on a non-`Con` type hitting `i64` via the old catch-all in a way the reorder breaks: the only caller is the forwarder over export-gated scalar/record types.) - [ ] **Step 6: Confirm `fn_scalar_sig` already handles a record** `crates/ailang-codegen/src/lib.rs:659-669` `fn_scalar_sig` returns `Some((params.clone(), (**ret).clone()))` for a `Type::Fn` (recon-quoted). It clones whole `Type`s — a `(con State)` param/ret is already carried through unchanged; the scalar-ness was only imposed downstream by `llvm_scalar`. No `fn_scalar_sig` edit is needed *iff* it does not itself filter on scalar-ness. Open `:659-669`; if the body is exactly the recon-quoted `Forall→inner; Type::Fn{params,ret}→Some((params,ret)); _→None`, add only a clarifying comment: ```rust // M3: params/ret may include a single-ctor scalar record type; // llvm_scalar maps it to `ptr`. fn_scalar_sig is type-shape only. ``` If instead it rejects non-scalar params (a `is_c_scalar`-like guard inside), remove that guard so a record param/ret survives extraction (the gate already guarantees only M3-valid records reach here). - [ ] **Step 7: Run the forwarder pin to verify GREEN** Run: `cargo test -p ailang-codegen --test embed_staticlib_lowering` Expected: PASS — `staticlib_record_forwarder_is_ptr_passthrough` green plus `staticlib_emits_forwarder_no_main` and `executable_target_still_emits_main` **byte-unchanged green** (the scalar forwarder IR is unaffected: `Int`→`i64`, `Float`→`double` arms preserved). - [ ] **Step 8: Confirm no `ailang-codegen` regression** Run: `cargo test -p ailang-codegen` Expected: PASS — full codegen suite green incl. Task-1 `embed_record_layout_pin` (unchanged: the box layout did not move) and the in-source `mod tests`. --- ## Task 4: E2E record round-trip — `own` (coherent-stop proof) Build `libbacktest.a` + `libailang_rt.a` from `embed_backtest_step_record.ail`, link a C host that constructs the input `State` via `ailang_rc_alloc`, calls `backtest_step` N times (kernel consumes each `own` input), frees the final return via `ailang_rc_dec`, and asserts **global leak-freedom** — Σallocs == Σfrees summed across *every* `ailang_rc_stats:` line the run emits (the `ailang_ctx_free` ctx readback AND the `g_rc_*` atexit line) — plus exit 0. > **Boss spec-consistency repair (2026-05-18 — M3.1 BLOCKED > adjudication, see the spec's repair note).** The original > single-ctx-readback `allocs == frees` model is unsatisfiable for > borrow (and only coincidentally passes for own): by M2's TLS-ctx > design the host's `make_state`/`dec` run outside the forwarder > call and land on `g_rc_*`, not ctx. The corrected, stronger > invariant is **global**: the harness sums *all* `ailang_rc_stats:` > lines; Σallocs == Σfrees (Σ`live` = 0) + exit 0, for both modes. > Verified empirically: own ctx `live=0`/g_rc `live=0`; borrow ctx > `live=+N`/g_rc `live=−N` (Σ=0) — the split is correct M2-TLS > behaviour, not a leak. **Files:** - Create: `crates/ail/tests/embed/record_roundtrip.c` - Create: `crates/ail/tests/embed_record_e2e.rs` - [ ] **Step 1: Write the C host (own + borrow via a compile-time switch)** Create `crates/ail/tests/embed/record_roundtrip.c` (the spec §"changed C host" verbatim for the `own` arm, plus a `-DBORROW` arm for Task 5; the frozen layout `{tag@0, Float acc@8, Int n@16}`, `make_state` via `ailang_rc_alloc(8 + 2*8)`): ```c #include #include #include #include typedef struct ailang_ctx ailang_ctx_t; extern ailang_ctx_t *ailang_ctx_new(void); extern void ailang_ctx_free(ailang_ctx_t *); extern void *ailang_rc_alloc(size_t); extern void ailang_rc_dec(void *); extern void *backtest_step(ailang_ctx_t *ctx, void *st, double sample); static void *make_state(double acc, int64_t n) { void *p = ailang_rc_alloc(8 + 2 * 8); /* header=1 set by runtime */ *(int64_t *)((char *)p + 0) = 0; /* single-ctor tag = 0 */ memcpy((char *)p + 8, &acc, 8); /* Float acc @ 8 */ *(int64_t *)((char *)p + 16) = n; /* Int n @ 16 */ return p; } int main(void) { ailang_ctx_t *ctx = ailang_ctx_new(); void *st = make_state(0.0, 0); for (int i = 0; i < 1000000; i++) { void *next = backtest_step(ctx, st, (double)(i & 7)); #ifdef BORROW ailang_rc_dec(st); /* borrow: host retained input, frees it */ #endif /* own: kernel consumed `st`; do NOT touch/dec it here. */ st = next; /* return is host-owned */ } double acc; memcpy(&acc, (char *)st + 8, 8); int64_t n = *(int64_t *)((char *)st + 16); assert(n == 1000000); ailang_rc_dec(st); /* host frees the final return */ ailang_ctx_free(ctx); /* AILANG_RC_STATS readback fires here */ return 0; } ``` - [ ] **Step 2: Write the failing `own` E2E test (RED)** Create `crates/ail/tests/embed_record_e2e.rs`. Open `crates/ail/tests/embed_e2e.rs` and **mirror its exact** build/link/ run incantation (the `ail build --emit=staticlib … -o`, the `cc` link of the C host against `lib.a` + `libailang_rt.a`, the `AILANG_RC_STATS=1` env). **The stats parse must sum ALL `ailang_rc_stats:` lines, not just the first** (Boss repair — the run emits two: the ctx readback and the `g_rc_*` atexit line; global leak-freedom is the invariant). The helper: ```rust #[derive(Debug)] struct RcStats { allocs: u64, frees: u64, exit_code: i32 } fn build_link_run_embed(fixture: &str, host_rel: &str, extra_cc: &[&str]) -> RcStats { // // SUM every "ailang_rc_stats:" line (ctx readback + g_rc atexit): let (mut allocs, mut frees) = (0u64, 0u64); for l in stderr.lines().filter(|l| l.starts_with("ailang_rc_stats:")) { for tok in l.split_whitespace() { if let Some(v) = tok.strip_prefix("allocs=") { allocs += v.parse::().unwrap(); } else if let Some(v) = tok.strip_prefix("frees=") { frees += v.parse::().unwrap(); } } } RcStats { allocs, frees, exit_code: run.status.code().unwrap_or(-1) } } #[test] fn record_roundtrip_own_alloc_eq_free() { let stats = build_link_run_embed( "embed_backtest_step_record.ail", "embed/record_roundtrip.c", &[/* no extra clang defines */], ); // GLOBAL leak-freedom: Σallocs == Σfrees across ctx + g_rc lines. assert_eq!(stats.allocs, stats.frees, "own: globally leak-free — every State box freed exactly once \ (kernel-consumed `own` inputs on ctx; host make_state/final \ dec on g_rc); {stats:?}"); assert_eq!(stats.exit_code, 0, "C host assert(n==1000000) held"); } ``` (Mirror `embed_e2e.rs`'s real build/link/run; only the **multi-line sum** is the Boss-repair delta over a single-line parse. Do not invent a new harness; reuse `embed_e2e.rs`'s incantation verbatim.) - [ ] **Step 3: Run the `own` E2E to verify RED** Run: `cargo test -p ail --test embed_record_e2e record_roundtrip_own_alloc_eq_free -- --exact --nocapture` Expected: FAIL — before Task 2+3 are both in the tree this fails to build the staticlib (gate rejects the record export) or mis-lowers the record; with Task 2+3 present it builds, and this step's RED is only if a real alloc/free imbalance exists. (Run order: this task executes after Tasks 2+3; the RED here pins the *alloc==free* property specifically, not the gate/forwarder which Tasks 2/3 pinned.) - [ ] **Step 4: Verify GREEN (own coherent-stop)** The `own` consume path is existing Iter-B machinery (spec §"Why the ownership contract is mechanically almost free"; ratified by `crates/ail/tests/e2e.rs::alloc_rc_own_param_dec_at_fn_return:1855` — **not** `borrow_own_demo_modes_are_metadata_only`, which is an Iter-18a pre-enforcement pin and must not be cited). No production code change is expected in this task — if the **global** Σallocs==Σfrees is RED for a real imbalance, that is a genuine bug: hand off to `debug` (RED test already exists). If it is GREEN once Tasks 2+3 are in the tree, that *is* the spec's "mechanically almost free" claim substantiated. Run: `cargo test -p ail --test embed_record_e2e record_roundtrip_own_alloc_eq_free -- --exact` Expected: PASS — Σallocs == Σfrees (own: ctx `live=0` + g_rc `live=0`; the two stat lines summed), exit 0. --- ## Task 5: E2E record round-trip — `borrow` Same harness, the `-DBORROW` arm: the kernel does **not** consume the input; the host retains and `ailang_rc_dec`s it itself. **Global** Σallocs == Σfrees again (Boss repair — *not* per-ctx-line: in borrow the ctx line is `live=+N` and the g_rc line `live=−N`, summing to 0; the host's decs land on g_rc because they run outside the forwarder's TLS-ctx window — correct M2 behaviour, not a leak). Tasks 4+5 together prove "ownership follows the declared mode" both directions, globally leak-free. **Files:** - Modify: `crates/ail/tests/embed_record_e2e.rs` (add the borrow test) - [ ] **Step 1: Write the failing `borrow` E2E test (RED)** In `crates/ail/tests/embed_record_e2e.rs` add (same multi-line-sum `build_link_run_embed` as Task 4 — global invariant): ```rust #[test] fn record_roundtrip_borrow_alloc_eq_free() { // -DBORROW: kernel (borrow param) does NOT consume the input; // host frees the retained input each iter + the final return. let stats = build_link_run_embed( "embed_backtest_step_record_borrow.ail", "embed/record_roundtrip.c", &["-DBORROW"], ); // GLOBAL: ctx allocs (kernel returns) + g_rc frees (host decs) // sum to a balanced total — Σallocs == Σfrees, Σlive == 0. assert_eq!(stats.allocs, stats.frees, "borrow: globally leak-free — kernel allocs returns on ctx, \ host frees inputs+return on g_rc; the two summed balance; {stats:?}"); assert_eq!(stats.exit_code, 0, "C host assert(n==1000000) held"); } ``` - [ ] **Step 2: Run the `borrow` E2E to verify RED then GREEN** Run: `cargo test -p ail --test embed_record_e2e record_roundtrip_borrow_alloc_eq_free -- --exact --nocapture` Expected: FAIL first if the borrow fixture is not yet built/lowered or the harness still parses only the first stat line; then PASS once the multi-line-sum harness is in place and `embed_backtest_step_record_borrow.ail` builds via the Task-2/3 path. A `borrow` param is **not** dropped by the kernel (ratified by `crates/ail/tests/e2e.rs::alloc_rc_borrow_only_recursive_list_drop:1671`); the host's per-iter `ailang_rc_dec(st)` on g_rc is what balances the **global** total. A real *global* imbalance ⇒ genuine bug ⇒ `debug`. - [ ] **Step 3: Both modes green together** Run: `cargo test -p ail --test embed_record_e2e` Expected: PASS, `2 passed` — `record_roundtrip_own_alloc_eq_free` and `record_roundtrip_borrow_alloc_eq_free`, both at global Σallocs==Σfrees. The frozen ownership contract is **proven both ways**, not asserted. --- ## Task 6: Freeze the layout — DESIGN.md SSOT + lockstep pointers Convert "provisional until M3" into one-way-freeze wording, add the frozen value-layout SSOT subsection, and point the three independent layout-encoding sites at it. The Task-1 byte-pin becomes the enforcing guard. Pin-safety: `docs_honesty_pin.rs:135` asserts DESIGN.md `:2297` ("Export parameters are written **bare**…"), which is **outside** both rewrite zones — it must stay byte-verbatim and contiguous (planner Step-5 item 6). **Files:** - Modify: `docs/DESIGN.md:2280-2282`, `:2293-2295`, insert before `:2319` - Modify: `runtime/rc.c:28-44` - Modify: `crates/ailang-codegen/src/match_lower.rs:107`/`:123-126`/`:128-136` - Modify: `crates/ailang-codegen/src/drop.rs:88`/`:113` - Modify: `crates/ailang-codegen/src/lib.rs:178`, `crates/ailang-core/src/ast.rs:211` - [ ] **Step 1: Confirm the docs-honesty pin baseline + the exact pinned line** Run: `cargo test -p ailang-core --test docs_honesty_pin` Expected: PASS (baseline before any DESIGN.md edit). Run: `grep -n 'Export parameters are written' docs/DESIGN.md` Expected: one hit at `:2297`. Record its exact byte content — Step 4 must NOT touch this line and must keep it contiguous. - [ ] **Step 2: Rewrite the two "provisional until M3" sentences** `docs/DESIGN.md:2280-2282` currently (recon-verbatim): ``` The M1 ABI is **scalar-only and provisional until M3**: every parameter and the return type must be `Int` (lowered `i64`) or `Float` (lowered `double`), and the fn's effect set must be empty. ``` Replace with (the scalar rule still holds for *scalar* exports; the record extension + freeze are stated; single contiguous sentences): ``` The embedding ABI accepts `Int` (lowered `i64`), `Float` (lowered `double`), or a single-constructor record of those (crossing as a bare `ptr` to the frozen box layout below); the fn's effect set must be empty. **Frozen as of M3**: a future compiler change MUST NOT move the box offsets below or invert the host-free rule. ``` `docs/DESIGN.md:2293-2295` currently (recon-verbatim): ``` Only the value/record layout remains provisional until M3; the ctx-threaded C signature is the M2 shape. ``` Replace with: ``` The value/record layout is **frozen as of M3** (see "Frozen value layout" below); the ctx-threaded C signature is the M2 shape. ``` - [ ] **Step 3: Run the docs-honesty pin to confirm Step 2 is pin-safe** Run: `cargo test -p ailang-core --test docs_honesty_pin` Expected: PASS — recon-confirmed no pin asserts any "provisional until M3" substring; `:2297` untouched. If RED, a pinned substring was in the rewrite zone — STOP, restore, and re-scope the edit to exclude the pinned bytes (planner Step-5 item 6). - [ ] **Step 4: Insert the frozen value-layout SSOT subsection** In `docs/DESIGN.md`, immediately before `## Data model` (recon: blank `:2318`, `## Data model` at `:2319`; insert at end of §"Embedding ABI", after `:2317`): ```markdown ### Frozen value layout (M3 — one-way commitment) For a single-constructor `data T` whose `n` fields are each `Int` or `Float`, a value of `T` crossing the embedding C boundary is a bare payload pointer `p`: | bytes | content | |---|---| | `p - 8 .. p` | `uint64_t` refcount header (`HEADER_SIZE = 8`) | | `p + 0 .. p + 8` | `int64_t` constructor tag (written; `0` for the single ctor — no elision) | | `p + 8 + i*8` | field `i`, declaration order: `int64_t` for `Int`, IEEE-754 `double` bit-pattern for `Float` | Total box payload size = `8 + n*8`. This is the layout codegen emits today (`match_lower.rs` `lower_ctor`); it is **frozen** — a future compiler change MUST NOT move these offsets. **Construction (host → kernel input).** The host MUST obtain an input record's storage from `ailang_rc_alloc(8 + n*8)` (returns the payload pointer with the refcount header pre-set to `1`, payload zeroed), then write the tag (`0`) and the scalar fields at the offsets above. A raw `malloc` is a contract violation: `own`-consume and `ailang_rc_dec` both require the runtime's 8-byte header at `p - 8` initialised to `1`. **Ownership follows the declared mode** (the §"Mode metadata is load-bearing for codegen" contract, as the C ABI): a `(own (con T))` parameter transfers ownership in — the kernel consumes it (Iter-B drop-at-return); the host MUST NOT touch or `dec` it after the call. A `(borrow (con T))` parameter is retained by the host — the kernel does not consume it; the host frees it. The return value is always owned by the host. **Free (host side).** `ailang_rc_dec(payload)`. Leak-free for an M3 record because every field is a scalar — `ailang_rc_dec` is header-only and an M3 record has no boxed children. A record with boxed fields (`Str`/`List`/nested record) is **not** an M3 type (rejected by the export gate); a recursive typed-free for that shape is an additive M4 concern, not a contradiction of this freeze. ``` - [ ] **Step 5: Add the lockstep-pointer comment at the three encoders** `runtime/rc.c:28-44` — at the end of the layout comment block (after the "fields from offset 8, env-cells …" sentence, before the "Single-threaded:" paragraph), add one line: ``` * FROZEN ABI for the embedding boundary — see DESIGN.md §"Embedding * ABI" > "Frozen value layout". A boundary-crossing single-ctor * scalar record's box offsets MUST NOT move. ``` `crates/ailang-codegen/src/match_lower.rs` — one line immediately above the `size_bytes = 8 + ...` computation (`:107`): ```rust // FROZEN ABI (embedding boundary) — DESIGN.md §"Embedding ABI" // > "Frozen value layout". size = 8 + n*8, tag@0, fields@8+i*8. ``` `crates/ailang-codegen/src/drop.rs` — one line above the tag load (`:88`) and above the `off = 8 + j*8` field-offset computation (`:113`): ```rust // FROZEN ABI (embedding boundary) — see DESIGN.md §"Embedding ABI". ``` - [ ] **Step 6: Fix the two stale rustdoc "provisional until M3" lines** `crates/ailang-codegen/src/lib.rs:178` and `crates/ailang-core/src/ast.rs:211` each carry a rustdoc fragment saying the layout/signature is "provisional until M3" (recon-named; DESIGN.md-current-state-mirror rule — these are now stale). Open each, replace the "provisional until M3" clause with "frozen as of M3 (DESIGN.md §\"Embedding ABI\" > \"Frozen value layout\")", preserving the rest of the doc line byte-for-byte. - [ ] **Step 7: Confirm the byte-pin is now the enforcing guard (spec item 5)** Run: `cargo test -p ailang-codegen --test embed_record_layout_pin` Expected: PASS (still — Task 6 changed no codegen offset, only comments+docs). Demonstrate enforceability (local, reverted): temporarily change `match_lower.rs:107` `8 + ...` to `16 + ...`, run the pin, observe RED, revert. Run: `sed -i 's/let size_bytes = 8 +/let size_bytes = 16 +/' crates/ailang-codegen/src/match_lower.rs ; cargo test -p ailang-codegen --test embed_record_layout_pin -- --exact heap_box_layout_is_frozen_8_plus_n_times_8_tag_at_0_fields_from_8 ; git checkout -- crates/ailang-codegen/src/match_lower.rs` Expected: the test run in the middle FAILS (`@ailang_rc_alloc(i64 24)` absent — emits `i64 32`), then `git checkout` restores the file. Re-run: `cargo test -p ailang-codegen --test embed_record_layout_pin` Expected: PASS — proves the freeze is enforced, not aspirational. (Use the exact `let size_bytes = 8 +` literal as it appears at `:107`; if the binding name differs, adjust the `sed` to the actual literal — confirm via `grep -n 'size_bytes' crates/ailang-codegen/src/match_lower.rs` first.) --- ## Task 7: Milestone-close verification gate Not a code change — the consolidated GREEN gate proving the "unmodified set stays green" + the full workspace is clean. Spec Testing items 6/7 (regression-green-unmodified), 8 (clean core), 9 (bench) — items 8/9 are the milestone-close `audit` skill's job (architect Invariant 1, bench trio); this task asserts only what an implement run can: the test-suite GREEN state. **Files:** none (verification only). - [ ] **Step 1: The "stays green unmodified" regression set** Run: `cargo test -p ail --test embed_e2e --test embed_rc_accounting_tsan --test embed_swarm_tsan --test embed_staticlib_cli --test embed_staticlib_alloc_guard --test print_no_leak_pin` Expected: PASS — every M1/M2 embed test + the executable-path leak pin green **unmodified** (M3 is additive: the scalar path is byte-preserved; recon named this exact set). Run: `cargo test -p ailang-core --test design_schema_drift ; cargo test -p ailang-check --test embed_export_hash_stable` Expected: PASS — no schema field added (explicit M2-style contrast; hashes/drift byte-unchanged). - [ ] **Step 2: Full workspace** Run: `cargo test --workspace 2>&1 | tail -15` Expected: PASS — every crate green. Record the total passed count; it must be the pre-M3 baseline + the net-new M3 tests (`embed_record_layout_pin` 1, `embed_export_gate` +4, `embed_staticlib_lowering` +1, `embed_record_e2e` 2) and no pre-existing test removed or its assertion weakened. - [ ] **Step 3: Round-trip invariant (no surface change)** Run: `cargo test -p ailang-surface --test round_trip` Expected: PASS — the new `.ail` fixtures round-trip; no Form-A surface was added (existing `data`/`ctor`/`own`/`borrow`/`match`/ `pat-ctor`/`app` only). --- ## Notes for the implement orchestrator - **Bench / architect are NOT this plan's job.** Spec Testing items 8 (clean-core Invariant 1) and 9 (bench trio carry-on) are the milestone-close `audit` skill (architect + bencher). Do not run `bench/check.py` in implement; record in the per-iter journal that audit owns the bench/architect close. - **Grounding-check carry-forward (do not violate):** (1) the own/borrow contract is ratified by `crates/ail/tests/e2e.rs::alloc_rc_own_param_dec_at_fn_return:1855` + `…::alloc_rc_borrow_only_recursive_list_drop:1671` — **never** cite `borrow_own_demo_modes_are_metadata_only:1330` (Iter-18a pre-enforcement pin) as the contract ratifier. (2) Task 1's byte-pin is a genuine RED-first new pin on the `@ailang_rc_alloc` heap path; the pre-existing `iter17a_local_box_alloca` pins the *alloca* path and is NOT a substitute. - **Boss-only-commits.** Leave all work uncommitted in the working tree; the Boss inspects `git diff` and commits the iter.