`(app print x)` under --alloc=rc leaked the heap-Str allocated by
`show x` in print's body: the let-binder `s` in
`(let s (app show x) (do io/print_str s))` was never flagged
trackable, so the drop site at scope-close emitted no
`ailang_rc_dec(s)`. Minimal repro `(body (app print 42))` produced
`allocs=1 frees=0 live=1`.
Root cause is two-layer:
1. examples/prelude.ail.json declared the Show class method `show`
without an explicit `ret_mode` on the return type, so serde
defaulted to ParamMode::Implicit. Fix: add `"ret_mode": "own"`
on the Show.show method, parallel to how the heap-Str-producing
builtins (int_to_str, bool_to_str, float_to_str, str_clone,
str_concat) declare it. examples/prelude.ail regenerated via
`ail render` to stay parse-isomorphic with the JSON.
2. crates/ailang-check/src/lib.rs `substitute_rigids` was silently
stripping `param_modes` and `ret_mode` whenever it rebuilt a
`Type::Fn`, so even after the prelude fix, the mono-synthesised
`show__Int / Bool / Str / Float` lost the `Own` annotation
during rigid substitution. Fix: preserve both fields through
the substitution.
RED pin: crates/ail/tests/print_no_leak_pin.rs (test
`alloc_rc_print_int_does_not_leak_show_result_str`) + fixture
examples/print_int_no_leak_pin.ail asserts allocs == frees and
live == 0 for `(body (app print 42))` under --alloc=rc.
Ten mono-body hash pins re-recorded (eq__Int/Bool/Str,
compare__Int/Bool/Str, show__Int/Bool/Str/Float, eq__IntBox) —
the bodies are semantically identical; the canonical JSON now
carries the previously-stripped mode metadata, so the body hash
moves. Re-pinning is bookkeeping for the intentional drift, not
a workaround.
Surfaced 2026-05-14 during the rpe.1 BLOCKED orchestrator run
(Cat A). Existed in latent form since iter 24.3 (when Show + print
shipped); only became user-observable when `(app print ...)` joined
the corpus. cargo test --workspace: 565 / 0 / 3.
Open follow-up flagged for next /audit: grep for `Type::Fn { ..., .. }`
field-spread sites — any other shape that drops modes during
rebuild is a latent instance of the same bug class. Out of scope
for this minimal fix.
First iteration of the form-a-default-authoring milestone. Mechanical:
one `ail render examples/prelude.ail.json > examples/prelude.ail`
captures the canonical Form-A projection (116 lines / 6386 bytes).
No source-code edits, no test edits.
The two auto-discovering roundtrip tests in
crates/ailang-surface/tests/round_trip.rs pick up the new fixture
via read_dir without test changes and both pass:
- every_ail_fixture_matches_its_json_counterpart — the .ail parses
to canonical bytes byte-equal to prelude.ail.json (gate test).
- parse_then_print_then_parse_is_idempotent_on_every_ail_fixture —
the Form-A printer is idempotent over prelude.
cargo test --workspace green at 558 tests (identical to pre-iter
baseline; audit-24 / iter-24.tidy lineage).
prelude.ail.json retained per spec §A2 — this is the singular
dual-form iter; iter 1 deletes it alongside the bulk test-infra
refactor.
Zero re-loops, zero review-phase repairs. Recon predictions matched
reality exactly (116 lines, 6386 bytes, `(module prelude` header,
558-test baseline). Strong signal that the iter-1+ bulk migration
mechanism is sound.