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.
23 KiB
eob.1 — Effect-op arguments borrow — Implementation Plan
Parent spec:
docs/specs/0020-effect-op-borrow.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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-202—float_to_strret_mode: Implicit→Own. - Modify:
crates/ailang-check/src/builtins.rs:203-212—int_to_strret_mode: Implicit→Own. - Modify:
crates/ailang-codegen/src/synth.rs:167-172—float_to_strsynth replayret_mode: Implicit→Own. - Modify:
crates/ailang-codegen/src/synth.rs:174-180—int_to_strsynth replayret_mode: Implicit→Own. - Modify:
crates/ailang-codegen/src/drop.rs:534-549— App arm ofdrop_symbol_for_bindergetsStrcarve-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— anchorTerm::Ctor.args[*] = Consume+Term::Do.args[*] = Borrowunder 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 Preludeentry'sdepends on:line accordingly. - Test: existing
crates/ail/tests/e2e.rs::int_to_str_drop_balances_rc_statsand::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-848fires a double-free under the new rule. Trace viaail emit-iron 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 Preludeentry'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.