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.
11 KiB
Effect-op arguments are borrowed — Design Spec
Date: 2026-05-12 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude
Goal
Codify and implement the language rule that arguments to a
Term::Do (effect-op call) are evaluated in borrow position,
not consume position. The uniqueness and linearity passes today
treat them as consumes, which both contradicts the intended
semantics ("effects observe; they do not transfer ownership of
their arguments") and produces a concrete bug: every heap-Str slab
handed back by int_to_str / float_to_str and immediately
printed via io/print_str leaks at scope close under
--alloc=rc.
The two RED tests at crates/ail/tests/e2e.rs::int_to_str_drop_balances_rc_stats
and ::str_field_in_adt_drops_heap_str_correctly (commit
592d87b) pin the leak; this milestone closes them. By doing so
it also closes the heap-str-abi milestone's outstanding hs.5
work (DESIGN anchor + WhatsNew + audit-close), which was held up
waiting for this gap to be named and fixed.
Architecture
The rule is symmetric with the already-shipped rule for
Term::Ctor.args:
| AST node | arg position | reason |
|---|---|---|
Term::Ctor.args |
Consume | constructor packs values into the cell; the cell owns them afterwards |
Term::Do.args |
Borrow (new) | effect-op observes; the caller still owns whatever pointer it passed in |
This is a language rule, not a per-op annotation. EffectOpSig
(crates/ailang-check/src/builtins.rs:32) deliberately stays as
it is: no param_modes, no ret_mode. The kind is the
ownership default — having an EffectOpSig at all means "borrow
in, no out-ownership", just as having a Ctor at all means
"consume in".
The rule lives at three check-time sites
crates/ailang-check/src/uniqueness.rs:289-292— today walksTerm::Do { args, .. }withPosition::Consume. Changes toPosition::Borrow. Consequence:consume_countof a bare-Varargument is no longer bumped by the effect-op call, which is the signal the let-arm dec emission atcrates/ailang-codegen/src/lib.rs:1430-1466reads when deciding whether to free the binder at scope close.crates/ailang-check/src/linearity.rs:506-510— today walksTerm::Do { args, .. }withPosition::Consume. Changes toPosition::Borrow. Consequence: a binder passed only to effect-ops no longer triggersuse-after-consumeon a second effect-op use. Twoio/print_str(s)calls in sequence become a normal double-borrow, not a linearity error.crates/ailang-check/src/linearity.rs:42— doc comment that lists per-arm consume/borrow positions. The bulletTerm::Ctor.args[*], Term::Do.args[*] — Consumesplits:Term::Ctor.args[*]stays in the Consume bullet;Term::Do.args[*]moves to the Borrow bullet alongsideTerm::Match.scrutineeandTerm::Clone.value.
The third site is a doc comment, but it is part of the rule: the documentation that lies about the analyser is itself the bug we are fixing.
DESIGN.md anchor
docs/DESIGN.md gets a new bullet under the
uniqueness / effect section naming the rule symmetrically with
the existing Ctor rule. This is the artefact the architect agent
reads at audit time, so the rule has a single named home that
future analyser code paths cannot accidentally diverge from.
Heap-Str-specific consequences
The rule alone does not close the heap-Str leak. Two ABI-level fixes follow from the rule's interaction with hs.4's runtime additions:
-
int_to_strandfloat_to_strswitch toret_mode: Own. The two functions allocate a fresh heap-Str slab viastr_alloc(runtime/str.c, iter hs.3) and hand the only outstanding pointer back to the caller. The codegen trackability gate atcrates/ailang-codegen/src/drop.rs:464-475(iter 18g.2:is_rc_heap_allocatedfor aTerm::Appchecks the callee'sret_mode == Own) is what tells the let-arm to emit a dec at scope close. Today both functions carryret_mode: Implicitatcrates/ailang-check/src/builtins.rs:203-212(and the float counterpart at:193-202) plus the codegen-side synth-replay atcrates/ailang-codegen/src/synth.rs:167-172(float_to_str) and:174-180(int_to_str). All four sites switch toOwn. -
drop_symbol_for_binderApp arm gets aStrcarve-out. Withret_mode: Own, the let-binder is tracked, and at scope close codegen routes throughdrop_symbol_for_binder(value=App, val_ssa=s)atcrates/ailang-codegen/src/drop.rs:534-549. That arm reads the call's static return type and formsdrop_<m>_<T>. ForT = Strthere is no such drop fn —Stris a built-in pointer type with no recursive children, the same case the field-drop arm already handles atcrates/ailang-codegen/src/drop.rs:372-394by returning"ailang_rc_dec". The carve-out adds the symmetric arm in the App branch:Type::Con { name: "Str", .. } → "ailang_rc_dec".
These two fixes are not part of the language rule. They are the shape the heap-Str realisation needs in order to benefit from the rule. A spec that named only the rule would leave the RED tests still red.
Components
Touched files:
crates/ailang-check/src/uniqueness.rs:289-292— Term::Do arg walk:Position::Consume→Position::Borrow.crates/ailang-check/src/linearity.rs:506-510— Term::Do arg walk:Position::Consume→Position::Borrow.crates/ailang-check/src/linearity.rs:42— doc-comment update: moveTerm::Do.args[*]from the Consume bullet to the Borrow bullet.crates/ailang-check/src/builtins.rs:193-202—float_to_strsignature:ret_mode: Implicit→Own.crates/ailang-check/src/builtins.rs:203-212—int_to_strsignature:ret_mode: Implicit→Own.crates/ailang-codegen/src/synth.rs:167-172—float_to_strsynth-replay:ret_mode: Implicit→Own.crates/ailang-codegen/src/synth.rs:174-180—int_to_strsynth-replay:ret_mode: Implicit→Own.crates/ailang-codegen/src/drop.rs:534-549— App arm ofdrop_symbol_for_binder: addType::Con { name: "Str", .. } → "ailang_rc_dec"carve-out before the genericformat!("drop_{m}_{name}")fallthrough.docs/DESIGN.md— new bullet anchoring theTerm::Do.args[*] = Borrowrule under the uniqueness / effect section.crates/ail/tests/e2e.rs::int_to_str_drop_balances_rc_stats— existing RED test; turns GREEN unchanged.crates/ail/tests/e2e.rs::str_field_in_adt_drops_heap_str_correctly— existing RED test; turns GREEN unchanged.crates/ail/tests/snapshots/— IR snapshots affected by the new let-arm dec atint_to_strcallsites may need regeneration. No snapshot in the currentcrates/ail/tests/snapshots/directory invokesint_to_strdirectly, so this is expected to be a no-op; flagged here in case planner-recon finds otherwise.
Data flow
The change does not affect canonical-JSON bytes. ret_mode: Own
is already a serialised ParamMode variant; Implicit is the
back-compat default which serialises via
skip_serializing_if = "ParamMode::is_implicit". Switching the
value does not change the schema or the existing-fixture hashes,
because no fixture in the workspace carries an explicit
ret_mode: Implicit annotation on int_to_str or float_to_str
(both are typechecker-installed, never user-written).
The change affects IR shape at exactly one class of callsite:
let x = (app int_to_str ...) in body (and float variant). The
new IR emits a call void @ailang_rc_dec(ptr %x) at scope close.
Non-binding sites — (do io/print_str (app int_to_str ...))
without an intervening let — are unaffected because no
let-binder exists for the dec to attach to.
Error handling
No new diagnostics. One existing diagnostic — over-strict-mode
(Iter 19a / 19a.1) — becomes more sensitive: a (own T)
parameter whose only use in the fn body is passing it to an
effect-op now correctly fires the lint, because the body no
longer counts that use as a consume.
This is the intended behaviour of the lint — the lint asks "is
this (own T) annotation actually justified by a consume in the
body?", and an effect-op call is not a consume. The flip surfaces
parameters that were silently mis-annotated and is part of the
milestone, not a regression.
Fixtures that fail the tightened lint will be re-annotated as part of this milestone's implementation iteration (planner will enumerate which ones during recon). If the planner finds the sweep nontrivial (more than a handful of fixtures), the re-annotation moves to its own follow-up iter rather than bloating the milestone.
Testing strategy
- Existing RED tests flip to GREEN.
int_to_str_drop_balances_rc_statsandstr_field_in_adt_drops_heap_str_correctlyat e2e.rs (commit592d87b). No fixture or assertion changes — the strictallocs == frees && live == 0invariants stay. - One new positive test: non-pointer effect-op arg. Bind a
let-Int, pass it to
io/print_int, run with--alloc=rcandAILANG_RC_STATS=1. Confirms the rule does not introduce spurious bookkeeping on primitive arguments (Int is not a pointer; there is no slab to free; allocs and frees stay at zero). - One new positive test: repeated effect-op borrow. Bind a
let-Str (heap-allocated via
int_to_str), pass it toio/print_strtwice in sequence, confirm the fixture typechecks (nouse-after-consume) and that the RC trace reportsallocs == frees && live == 0. - Workspace-wide regression.
cargo test --workspace,bench/cross_lang.py,bench/compile_check.py,bench/check.pyall green. Anyover-strict-mode-driven fixture re-annotations land in the same milestone.
Acceptance criteria
- Both existing RED tests at
crates/ail/tests/e2e.rspass withallocs == frees && live == 0. - Two new positive tests added (Int-arg primitive, repeated Str-arg borrow) — both green.
cargo test --workspace,bench/cross_lang.py,bench/compile_check.py,bench/check.pyall green; anyover-strict-modefailures resolved by fixture re-annotation inside the milestone.docs/DESIGN.mdcarries a new bullet under the uniqueness / effect section naming theTerm::Do.args[*] = Borrowrule, symmetric with the existingTerm::Ctor.args[*] = Consumetreatment.docs/WhatsNew.mdcarries one entry written for the user-as-reader covering both the heap-Str RC close and the effect-op argument rule, in plain prose, no internals.- The heap-str-abi milestone is effectively closed by this milestone — hs.5's "DESIGN anchor + WhatsNew + audit-close" rolls in here. No separate hs.5 ships.