# Embedding ABI — M3.1 — Implementation Plan

> **Parent spec:** `docs/specs/0043-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_<mod>_<fn>`). (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.

// <mirror embed_staticlib_lowering.rs's load+lower helper here:
//  load examples/embed_record_layout_carrier.ail, lower to IR string `ir`>

#[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_<mod>_<fn>`.

**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 <stdint.h>
#include <assert.h>
#include <string.h>
#include <stdlib.h>

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<module>.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 {
    // <build --emit=staticlib + cc link + run AILANG_RC_STATS=1,
    //  mirrored verbatim from embed_e2e.rs's real incantation>
    // 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::<u64>().unwrap(); }
            else if let Some(v) = tok.strip_prefix("frees=") { frees += v.parse::<u64>().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.
