Files
AILang/docs/plans/0047-eob.1.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

23 KiB

eob.1 — Effect-op arguments borrow — Implementation Plan

Parent spec: docs/specs/0020-effect-op-borrow.md

For agentic workers: REQUIRED SUB-SKILL: use skills/implement to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Land the language rule that Term::Do.args[*] are walked in Position::Borrow by the uniqueness and linearity passes, together with the two heap-Str-specific shape fixes (ret_mode: Own for int_to_str / float_to_str; Str carve-out in the App arm of drop_symbol_for_binder) that make the existing RED tests at crates/ail/tests/e2e.rs flip to GREEN under --alloc=rc. Close the heap-str-abi milestone in the same iter via DESIGN anchor + WhatsNew + roadmap update.

Architecture: Five layers, one iteration. (1) Two analyser walkers + their shared doc-comment flip the Term::Do arm from Consume to Borrow. (2) Four signature sites (builtins.rs + synth.rs, lockstep pairs for int_to_str and float_to_str) switch ret_mode from Implicit to Own. (3) One drop-fn-symbol picker site adds a Type::Con { name: "Str", .. } carve-out in the App arm, symmetric to the existing carve-out in field_drop_call. (4) The two existing RED tests at e2e.rs (commit 592d87b) flip to GREEN unchanged; two new positive tests pin (a) a primitive Int effect-op arg (no RC bookkeeping) and (b) a repeated Str borrow through two io/print_str calls (linearity allows; RC balances). (5) DESIGN.md anchors both arg-position rules (Ctor = Consume, Do = Borrow) under Decision 10; WhatsNew.md gets the milestone entry; roadmap.md checks off the heap-Str ABI P1 entry.

Tech Stack: ailang-check (uniqueness + linearity passes), ailang-codegen (synth-replay table + drop machinery), ail (E2E tests), runtime/str.c already in place from hs.3.

Files this plan creates or modifies:

  • Modify: crates/ailang-check/src/uniqueness.rs:289-292 — Term::Do arg-walk Consume → Borrow.
  • Modify: crates/ailang-check/src/linearity.rs:506-510 — Term::Do arg-walk Consume → Borrow.
  • Modify: crates/ailang-check/src/linearity.rs:42 — module doc-comment: split Ctor + Do bullet.
  • Modify: crates/ailang-check/src/builtins.rs:193-202float_to_str ret_mode: ImplicitOwn.
  • Modify: crates/ailang-check/src/builtins.rs:203-212int_to_str ret_mode: ImplicitOwn.
  • Modify: crates/ailang-codegen/src/synth.rs:167-172float_to_str synth replay ret_mode: ImplicitOwn.
  • Modify: crates/ailang-codegen/src/synth.rs:174-180int_to_str synth replay ret_mode: ImplicitOwn.
  • Modify: crates/ailang-codegen/src/drop.rs:534-549 — App arm of drop_symbol_for_binder gets Str carve-out.
  • Modify: crates/ail/tests/e2e.rs — append two new positive tests near :2624.
  • Create: examples/int_to_print_int_borrow.ail.json — fixture for primitive-Int effect-op-arg test.
  • Create: examples/heap_str_repeated_print_borrow.ail.json — fixture for repeated-Str-borrow test.
  • Modify: docs/DESIGN.md — anchor Term::Ctor.args[*] = Consume + Term::Do.args[*] = Borrow under Decision 10.
  • Modify: docs/WhatsNew.md — append milestone entry (newest at bottom).
  • Modify: docs/roadmap.md:41-56 — close [milestone] Heap-Str ABI (check off, then remove); update the dependent [milestone] Post-22 Prelude entry's depends on: line accordingly.
  • Test: existing crates/ail/tests/e2e.rs::int_to_str_drop_balances_rc_stats and ::str_field_in_adt_drops_heap_str_correctly — verify GREEN unchanged after Tasks 1-3 land.

Task 1: Term::Do arg-walks flip Consume → Borrow

Files:

  • Modify: crates/ailang-check/src/uniqueness.rs:289-292
  • Modify: crates/ailang-check/src/linearity.rs:506-510
  • Modify: crates/ailang-check/src/linearity.rs:42 (doc comment)

The three sites are atomic — analyser code paths and their documentation must stay in sync. Flip all three in one task.

  • Step 1: Confirm RED status of the leak's pinned tests.

Run: cargo test --workspace -p ail --test e2e int_to_str_drop_balances_rc_stats str_field_in_adt_drops_heap_str_correctly

Expected: both tests FAIL. int_to_str_drop_balances_rc_stats fails on assert_eq!(allocs, frees) with allocs=1, frees=0. str_field_in_adt_drops_heap_str_correctly fails on the same assert with allocs=2, frees=1. These two failures are the RED pin from commit 592d87b.

  • Step 2: Flip uniqueness.rs Term::Do arm.

In crates/ailang-check/src/uniqueness.rs, at the Term::Do { args, .. } arm near line 289, change:

Term::Do { args, .. } => {
    for a in args {
        self.walk(a, Position::Consume);
    }
}

to:

Term::Do { args, .. } => {
    for a in args {
        self.walk(a, Position::Borrow);
    }
}
  • Step 3: Flip linearity.rs Term::Do arm.

In crates/ailang-check/src/linearity.rs, at the Term::Do { args, .. } arm near line 506, change Position::Consume to Position::Borrow in the same shape as Step 2. The Ctor arm immediately above (:500-505) stays on Position::Consume — the asymmetry is the rule.

  • Step 4: Update linearity.rs:42 doc comment.

Find the bullet at crates/ailang-check/src/linearity.rs:42:

//! - `Term::Ctor.args[*]`, `Term::Do.args[*]` — Consume.

Replace with two bullets that match the new code structure:

//! - `Term::Ctor.args[*]` — Consume (the new value owns them).
//! - `Term::Do.args[*]` — Borrow (effect-op observes; ownership
//!   stays with the caller).

The Borrow bullet sits alongside Term::Match.scrutinee (:37-38) and Term::Clone.value (:35-36) as the other Borrow-position sites.

  • Step 5: Build the check crate to confirm no compile errors.

Run: cargo build -p ailang-check

Expected: clean build. (At this point, the heap-Str-specific shape fixes from Tasks 2-3 are still missing, so the RED tests stay RED. That's the design — Tasks 1, 2, 3 are mutually dependent for the RED→GREEN flip.)

  • Step 6: Workspace-wide regression on the analyser layer.

Run: cargo test --workspace -p ailang-check

Expected: all green. If any test fires (likely a use-after-consume case that previously triggered on a binder passed twice into an effect-op), inspect: the test's old behaviour was wrong (effect-ops do not consume), and the test should be retired or updated. Do not revert Steps 2-4.


Task 2: int_to_str / float_to_str ret_mode Implicit → Own

Files:

  • Modify: crates/ailang-check/src/builtins.rs:193-202 (float_to_str)
  • Modify: crates/ailang-check/src/builtins.rs:203-212 (int_to_str)
  • Modify: crates/ailang-codegen/src/synth.rs:167-172 (float_to_str synth)
  • Modify: crates/ailang-codegen/src/synth.rs:174-180 (int_to_str synth)

Four lockstep edits. The checker side and the codegen-synth side must agree byte-for-byte on each builtin's Type::Fn.

  • Step 1: Flip builtins.rs float_to_str ret_mode.

In crates/ailang-check/src/builtins.rs, at the float_to_str env.globals.insert near line 193, change:

ret_mode: ailang_core::ast::ParamMode::Implicit,

to:

ret_mode: ailang_core::ast::ParamMode::Own,

(The literal sits at line 200 inside the surrounding insert block at lines 193-202.)

  • Step 2: Flip builtins.rs int_to_str ret_mode.

In crates/ailang-check/src/builtins.rs, at the int_to_str env.globals.insert near line 203, change the ret_mode: Implicit literal (line 210) to ret_mode: Own in the same shape as Step 1.

  • Step 3: Flip synth.rs float_to_str ret_mode.

In crates/ailang-codegen/src/synth.rs, at the "float_to_str" arm near line 167, change the ret_mode: ParamMode::Implicit literal (line 172) to ret_mode: ParamMode::Own.

  • Step 4: Flip synth.rs int_to_str ret_mode.

In crates/ailang-codegen/src/synth.rs, at the "int_to_str" arm near line 174, change the ret_mode: ParamMode::Implicit literal (line 179) to ret_mode: ParamMode::Own.

  • Step 5: Workspace build to confirm symmetry.

Run: cargo build --workspace

Expected: clean build. If a divergence between the two layers exists (e.g. one site flipped but not its partner), the type-replay machinery at the codegen-side fires an internal: type mismatch error at any fixture that exercises int_to_str / float_to_str.

  • Step 6: Confirm the two RED tests now report a different shape.

Run: cargo test --workspace -p ail --test e2e int_to_str_drop_balances_rc_stats

Expected: still FAILS, but now likely on a LINK error or a wrong drop-fn symbol — because the let-binder is now trackable (is_rc_heap_allocated succeeds on ret_mode == Own), but drop_symbol_for_binder's App arm currently emits drop_<m>_Str which is undefined. This is the gap Task 3 closes; observe and move on.

(If the test now fails for a different reason — e.g. compile error, or unexpected stdout — stop and investigate. The expected failure shape is link-time / runtime "undefined symbol drop__Str" or a similar drop-fn-not-found.)


Task 3: drop_symbol_for_binder App arm gets Str carve-out

Files:

  • Modify: crates/ailang-codegen/src/drop.rs:534-549

  • Step 1: Read the current App arm.

crates/ailang-codegen/src/drop.rs:534-549:

Term::App { .. } => {
    if let Ok(ret_ty) = self.synth_arg_type(value) {
        if let Type::Con { name, .. } = ret_ty {
            if name.matches('.').count() == 1 {
                let (prefix, suffix) =
                    name.split_once('.').expect("checked");
                if let Some(target) = self.import_map.get(prefix) {
                    return format!("drop_{target}_{suffix}");
                }
                return format!("drop_{prefix}_{suffix}");
            }
            return format!("drop_{m}_{name}", m = self.module_name);
        }
    }
    "ailang_rc_dec".to_string()
}

The unqualified-name path at the inner return format!("drop_{m}_{name}") generates drop_<current_module>_Str for name == "Str", which is undefined.

  • Step 2: Insert the Str carve-out before the unqualified-name return.

Change the Type::Con body to special-case Str before the qualified / unqualified routing. The cleanest shape mirrors field_drop_call's Str arm at drop.rs:392-394:

Term::App { .. } => {
    if let Ok(ret_ty) = self.synth_arg_type(value) {
        if let Type::Con { name, .. } = ret_ty {
            // Symmetric to `field_drop_call`'s Str arm: Str is a
            // built-in pointer type with no per-type drop fn. Both
            // heap-Str (rc_header at payload-8) and static-Str
            // (codegen-elision keeps static pointers out of this
            // path) consume via `ailang_rc_dec`.
            if name == "Str" {
                return "ailang_rc_dec".to_string();
            }
            if name.matches('.').count() == 1 {
                let (prefix, suffix) =
                    name.split_once('.').expect("checked");
                if let Some(target) = self.import_map.get(prefix) {
                    return format!("drop_{target}_{suffix}");
                }
                return format!("drop_{prefix}_{suffix}");
            }
            return format!("drop_{m}_{name}", m = self.module_name);
        }
    }
    "ailang_rc_dec".to_string()
}
  • Step 3: Build the codegen crate.

Run: cargo build -p ailang-codegen

Expected: clean build.


Task 4: Verify existing RED tests flip to GREEN

Files: none modified — this task is observational.

After Tasks 1-3, the two pre-existing RED tests at crates/ail/tests/e2e.rs:2601, :2617 should pass with strict allocs == frees && live == 0 invariants.

  • Step 1: Run the two target tests.

Run: cargo test --workspace -p ail --test e2e int_to_str_drop_balances_rc_stats str_field_in_adt_drops_heap_str_correctly

Expected: both PASS. Concrete numbers:

  • int_to_str_drop_balances_rc_stats: allocs == 1, frees == 1, live == 0, stdout "42\n".
  • str_field_in_adt_drops_heap_str_correctly: allocs == 2, frees == 2, live == 0, stdout "42\n".

If either is still RED, stop and inspect. The most likely failure modes:

  • One of Tasks 1-3 was not fully applied (re-check files).

  • The two analyser-walkers diverged (Task 1 only flipped uniqueness, not linearity, or vice versa).

  • A pattern-binder-drop interaction in match_lower.rs:740-848 fires a double-free under the new rule. Trace via ail emit-ir on the failing fixture.

  • Step 2: Full e2e regression.

Run: cargo test --workspace -p ail --test e2e

Expected: every test PASS. If any other E2E fires (a fixture whose behaviour relied on the old Consume semantics), inspect — likely a fixture where a let-bound pointer was previously NOT freed because it was double-counted as consumed-by-effect-op, and is now freed correctly. Such tests need re-baselining, not reversion of Tasks 1-3.


Task 5: Two new positive tests + fixtures

Files:

  • Create: examples/int_to_print_int_borrow.ail.json

  • Create: examples/heap_str_repeated_print_borrow.ail.json

  • Modify: crates/ail/tests/e2e.rs (append two tests near line 2624)

  • Step 1: Write the primitive-Int fixture.

Create examples/int_to_print_int_borrow.ail.json:

{
  "schema": "ailang/v0",
  "name": "int_to_print_int_borrow",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "doc": "Iter eob.1: primitive Int passed to io/print_int. The rule that Term::Do args are Borrow has no observable RC effect here because Int is unboxed; the test pins that no spurious bookkeeping appears (allocs == 0, frees == 0, live == 0).",
      "body": {
        "t": "let",
        "name": "n",
        "value": { "t": "lit", "lit": { "kind": "int", "value": 7 } },
        "body": {
          "t": "do",
          "op": "io/print_int",
          "args": [{ "t": "var", "name": "n" }]
        }
      }
    }
  ]
}
  • Step 2: Write the repeated-Str-borrow fixture.

Create examples/heap_str_repeated_print_borrow.ail.json:

{
  "schema": "ailang/v0",
  "name": "heap_str_repeated_print_borrow",
  "imports": [],
  "defs": [
    {
      "kind": "fn",
      "name": "main",
      "type": {
        "k": "fn",
        "params": [],
        "ret": { "k": "con", "name": "Unit" },
        "effects": ["IO"]
      },
      "params": [],
      "doc": "Iter eob.1: heap-Str passed to io/print_str twice in sequence. Under the new rule (Term::Do args = Borrow), the second call no longer triggers use-after-consume; the linearity check accepts the program. Under --alloc=rc + AILANG_RC_STATS=1 the slab allocates once and is freed once at scope close (allocs == 1, frees == 1, live == 0).",
      "body": {
        "t": "let",
        "name": "s",
        "value": {
          "t": "app",
          "fn": { "t": "var", "name": "int_to_str" },
          "args": [{ "t": "lit", "lit": { "kind": "int", "value": 42 } }]
        },
        "body": {
          "t": "seq",
          "lhs": {
            "t": "do",
            "op": "io/print_str",
            "args": [{ "t": "var", "name": "s" }]
          },
          "rhs": {
            "t": "do",
            "op": "io/print_str",
            "args": [{ "t": "var", "name": "s" }]
          }
        }
      }
    }
  ]
}
  • Step 3: Append the two e2e tests.

In crates/ail/tests/e2e.rs, append after the existing str_field_in_adt_drops_heap_str_correctly test (currently the file ends at line 2624):

/// Iter eob.1: primitive-Int arg passed to an effect-op. Under the
/// new "Term::Do args = Borrow" rule, the let-binder `n` is not
/// consumed by `io/print_int`; but Int is unboxed, so there is no
/// pointer to RC-track. Pin: zero allocs / zero frees / zero live —
/// the rule introduces no spurious bookkeeping on primitives.
#[test]
fn int_arg_to_effect_op_does_not_rc_track() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("int_to_print_int_borrow.ail.json");
    assert_eq!(stdout, "7\n");
    assert_eq!(allocs, 0, "Int arg should not trigger any RC alloc; got allocs={allocs}");
    assert_eq!(frees, 0, "Int arg should not trigger any RC free; got frees={frees}");
    assert_eq!(live, 0, "no leaked RC slabs allowed; live={live}");
}

/// Iter eob.1: heap-Str let-binder consumed by two `io/print_str`
/// calls in sequence. Pins the linearity-side consequence of the new
/// rule: under the old Position::Consume walk for Term::Do args, the
/// second print would have triggered `use-after-consume`. Under the
/// new Position::Borrow walk, both calls are borrows and the program
/// typechecks. Under --alloc=rc the slab is allocated once by
/// int_to_str, lives through both prints, and is freed exactly once
/// at scope close — allocs == 1, frees == 1, live == 0.
#[test]
fn heap_str_repeated_print_balances_rc_stats() {
    let (stdout, allocs, frees, live) =
        build_and_run_with_rc_stats("heap_str_repeated_print_borrow.ail.json");
    assert_eq!(stdout, "42\n42\n");
    assert_eq!(allocs, 1, "expected exactly one heap-Str slab; got allocs={allocs}");
    assert_eq!(frees, 1, "expected exactly one free; got frees={frees}");
    assert_eq!(live, 0, "no leaked heap-Str slabs allowed; live={live}");
}
  • Step 4: Run the two new tests.

Run: cargo test --workspace -p ail --test e2e int_arg_to_effect_op_does_not_rc_track heap_str_repeated_print_balances_rc_stats

Expected: both PASS.

If heap_str_repeated_print_balances_rc_stats fails with the wrong allocs/frees numbers, the most likely cause is that one of the two analyser walkers in Task 1 was not flipped — the linearity walker would still reject the second print as use-after-consume, and the program would not typecheck.


Task 6: DESIGN.md anchor for both arg-position rules

Files:

  • Modify: docs/DESIGN.md (under Decision 10)

The spec mandates anchoring Term::Do.args[*] = Borrow. The existing symmetric Ctor rule (Term::Ctor.args[*] = Consume) is documented only in crates/ailang-check/src/linearity.rs:42. The Boss decision recorded at brainstorm-close is: anchor BOTH rules together to avoid codifying asymmetry in DESIGN.md.

  • Step 1: Locate Decision 10's body.

Run: grep -n "Decision 10\|RC + uniqueness" /home/brummel/dev/ailang/docs/DESIGN.md | head -5

Expected: a line near or after the canonical Decision 10 header (a bullet such as "Decision 10: RC + uniqueness inference" or similar). Read the surrounding section to find the right anchor location — typically near the existing param-mode / borrow-mode discussion.

  • Step 2: Append the arg-position-table subsection.

Under Decision 10, add a small subsection that names both rules:

#### Arg-position policy for compound AST nodes

The uniqueness and linearity passes walk arguments of compound
nodes with a fixed Position policy. For ownership-bearing nodes:

| Node              | Arg position | Reason                                                                |
|-------------------|--------------|----------------------------------------------------------------------|
| `Term::Ctor.args[*]` | Consume   | constructor packs values into the cell; the cell owns them afterwards |
| `Term::Do.args[*]`   | Borrow    | effect-op observes its arguments; the caller still owns whatever pointer it passed in |

The two policies are language rules, not per-op annotations. They
do not appear as fields on `EffectOpSig` or `Ctor`; the AST node
kind itself carries the default. The walkers that read this policy
live at `crates/ailang-check/src/uniqueness.rs` and
`crates/ailang-check/src/linearity.rs` (matched arms in both).

The exact wording / placement is a Boss judgement call — adapt if Decision 10's existing structure suggests a better fit. The non-negotiable elements: both rules are named together, both as language rules not per-op fields, with code-location references.

  • Step 3: Verify DESIGN.md still validates structurally.

Run: cargo test --workspace

Expected: any DESIGN-md-shape tests (if present) stay green. The contents of DESIGN.md are not parsed by the toolchain; this is just a final regression safety net.


Task 7: WhatsNew.md entry + roadmap close

Files:

  • Modify: docs/WhatsNew.md (append at bottom)

  • Modify: docs/roadmap.md:41-56 (close Heap-Str ABI entry)

  • Step 1: Append a WhatsNew entry.

In docs/WhatsNew.md, append at the end (newest at bottom per chronological convention):

## 2026-05-12 — Strings produced at runtime now release cleanly

`int_to_str`, `float_to_str`, and similar built-in primitives that
produce a freshly allocated string at runtime now participate in
reference counting like every other heap value. A string passed
into a print call is now treated as borrowed, not consumed, so
it is freed exactly once when it leaves scope. Earlier programs
that converted a number to a string and printed it leaked the
string buffer at program exit; that no longer happens.

A side effect: a function parameter declared `(own T)` whose only
in-body use was passing it into a print call will now correctly
be flagged as over-strict — that annotation should be relaxed to
`(borrow T)` (or removed). No current example programs are
affected.

(Exact prose may shift slightly; the constraint is: no internal identifiers, no iter codes, lead with the user-visible change.)

  • Step 2: Close the Heap-Str ABI roadmap entry.

In docs/roadmap.md at the P1 milestone bullet (lines 41-56), check off the entry:

Change - [ ] **\[milestone\]** Heap-Str ABI — ... to - [x] **\[milestone\]** Heap-Str ABI — ...

Per roadmap convention, a finished entry may stay briefly with the [x] marker before being removed. For this iter, leave the [x] line in place; the Boss may remove it during commit-shape or in a follow-up edit once it stops being load-bearing context.

  • Step 3: Update the dependent Post-22 Prelude entry's depends-on.

Run: grep -n 'depends on: Heap' /home/brummel/dev/ailang/docs/roadmap.md

For each match (recon found at least one at :69), update the depends on: line to reflect that the dependency has shipped — either:

  • Remove the depends on: line entirely (preferred if no other dependency remains), or
  • Replace with depends on: <something> if another live dependency exists.

The Post-22 Prelude entry has only the Heap-Str dependency; remove the line.

  • Step 4: Final full-workspace regression.

Run: cargo test --workspace && bench/cross_lang.py && bench/compile_check.py && bench/check.py

Expected: every gate green. If any bench script fails on a fixture whose RC trace shifted (a previously-leaking program now reaches live == 0), update the baseline in the same iter — it is the intended consequence of the milestone.