# audit-24 — milestone close (Show + print rewire)

**Date:** 2026-05-13
**Milestone:** Show + print rewire (number-24 family)
**Iters covered:** 24.1 (`f38bad8`, shipped 2026-05-12) → 24.2 (`3286117`) → 24.3 (`246b5c7`)
**Range from previous close:** `0dcdaab..0cfb3f6` (audit-ct-tidy → iter-24.3 INDEX)
**Status:** drift_found — `24.tidy` queued

## Summary

The Show + print milestone shipped in three iters: 24.1 installed
`bool_to_str` + `str_clone` runtime + codegen primitives at heap-Str
ABI level (pre-mq audit lineage); 24.2 added `class Show` + four
primitive instances (Int/Bool/Str/Float) to the prelude and migrated
the 22b user-Show fixture corpus to `TShow`/`tshow` to eliminate
post-prelude.Show ambiguity; 24.3 added `fn print` with explicit-let
body, three E2E fixtures (positive 4-prim, user-ADT, negative
NoInstance), an IR-shape pin on `print__Int.body`, a Show-aware
NoInstance diagnostic, plus three compiler-path repairs surfaced by
the user-ADT E2E (canonical-form normalisation of
`MonoTarget::FreeFn::type_args`, codegen cross-module reference
fallback, FreeFnCall constraint-residual push).

Architect drift review surfaces 7 items: three [high] (missing DESIGN.md
documentation of the three iter-24.3 strengthenings: canonical-form
`type_args`, post-mono cross-module-ref invariant, FreeFnCall constraint-
residual push; missing codegen-level pin for the import_map-fallback
path; constraint-residual push currently fires only in the dot-qualified
FreeFnCall branch), three [medium] (`unwrap_or_default` in the
method-name lookup masks class-index drift; `normalize_type_for_lookup`
duplicated across two `mono.rs` sites with manual-lockstep invariant;
negative-test coverage is single-shape — only `f : Int -> Int`, not
user-type-without-instance), and one [low] (bench `architect_sweeps.sh`
noise on three pre-milestone-24 DESIGN.md lines — not new drift).

Bench: `bench/check.py` exit 1 with 2 regressed / 1 improved metrics
(metric identities migrate between runs — first run hit
`latency.implicit_at_rc.max_us +209%`, second run hit
`latency.explicit_at_rc.max_us +66%` + `bench_list_sum.bump_s +11.9%`).
`bench/compile_check.py` exit 1 with 3-5 regressed metrics across
runs (metric identities migrate: first run hit `build_O0_ms.*`
cluster within tolerance, second run hit `check_ms.*` cluster with
several +25-34% deltas). `bench/cross_lang.py` exit 0, 25/25 stable.
**9th consecutive audit observation** of the documented noise envelope
covering `latency.implicit_at_rc.*` / `latency.explicit_at_rc.*` /
`bench_list_sum.bump_s` / `check_ms.*` metric-cluster migration
across consecutive runs. Conservative-call convention has held the
baseline pristine across 8 prior audits since `audit-cma`; this
audit extends the lineage to 9 with the same conservative call —
the metric-migration-between-runs is itself attribution evidence
that the signal is variance, not regression.

Resolution path: `24.tidy` iteration covers 5 actionable drift
items (three [high] + two [medium] documentable/refactorable). The
[medium] "negative-test coverage single-shape" item moves to the
roadmap P3 backlog. The [low] bench_sweep noise is carry-on.

## Architect drift items

### [high-1] Three iter-24.3 strengthenings undocumented in DESIGN.md

**Paths:** `crates/ailang-check/src/mono.rs:687-696` + `:1287-1294`,
`crates/ailang-codegen/src/lib.rs:1980-2000` + `:2200-2244` +
`:2776-2816`, `crates/ailang-check/src/lib.rs:2820-2865`,
`docs/DESIGN.md` §"Monomorphisation" or new subsection.

Iter 24.3 silently strengthened three load-bearing mono / codegen /
synth invariants without documenting them in DESIGN.md:

- (a) `MonoTarget::FreeFn::type_args` carries canonical (`<owner>.<bare>`)
  types post-collection via `normalize_type_for_lookup`. Pre-iter
  invariant was "subst.apply result"; new invariant is
  "subst.apply result normalised to registry-lookup form".
- (b) Post-mono synthesised bodies may carry cross-module references
  to modules their source template didn't import. Codegen falls back
  to direct `module_user_fns` / `module_def_ail_types` lookup when
  the prefix isn't in the current module's `import_map`.
- (c) FreeFnCall synth pushes one `ResidualConstraint` per declared
  forall-constraint with rigid vars substituted by fresh metavars.
  Pre-iter, the arm had `constraints: _` (constraints ignored);
  latent for milestone-23 fixtures because all primitive instances
  discharged silently.

Future planner / implementer work that touches these surfaces will
not see the strengthenings without a doc anchor.

**Outcome:** fix in `24.tidy` (doc-only).

### [high-2] Codegen import_map fallback path has no unit-level pin

**Path:** `crates/ailang-codegen/src/lib.rs:2200-2244` + `:1980-2000` +
`:2776-2816`.

The three cross-module-fallback arms (`resolve_top_level_fn`,
`lower_app` cross-module arm, `synth_with_extras` Var arm) introduce
a new dispatch path: try `import_map`, fall back to direct
`module_user_fns` / `module_def_ail_types` lookup. The fallback is
**only** justified by post-mono synthesised cross-module references.
No codegen-level unit test pins the failure mode; the only test that
would catch a regression is the E2E `show_user_adt.ail.json`. If a
future refactor tightens `resolve_top_level_fn` back to import_map-only,
the regression surfaces only at E2E (slow to bisect).

**Outcome:** fix in `24.tidy` — add a unit pin asserting the fallback
resolves for a synthesised `prelude.print__T` → `<user_module>.show__T`
pair without going through `import_map`.

### [high-3] FreeFnCall constraint-residual push covers only dot-qualified branch

**Path:** `crates/ailang-check/src/lib.rs:2820-2865`.

The constraint-residual push happens **only** in the dot-qualified
`prefix.suffix` synth branch (the only branch that populates
`free_fn_owner = Some(...)`). Bare-name references to polymorphic free
fns that resolve through other branches (locals shadow,
`env.module_globals` direct hit at the current module, etc.) do not
push residuals for declared constraints. For prelude poly fns this is
fine because the auto-injected prelude call-path always lands in the
dot-qualified branch — but the invariant is implicit and undocumented.
The same "silent unknown variable from codegen" failure mode existed
pre-iter-24.3 for any milestone-23 negative case (`ne` on a type
without `Eq` instance, etc.); the iter-24.3 fix covers Show only
because that's what the negative E2E exercised.

**Outcome:** fix in `24.tidy` — generalise the constraint-residual push
to all FreeFnCall synth branches, OR document the invariant
"poly free fns always reach synth via the dot-qualified branch" and
pin it with a unit test.

### [medium-1] `unwrap_or_default` in method-name lookup masks class-index drift

**Path:** `crates/ailang-check/src/lib.rs:2852-2858`.

Method-name lookup in the FreeFnCall constraint-residual push uses
`unwrap_or_default()` (empty string fallback) when `class_methods` has
no entry for the residual class. A user constraint referencing a class
that does not exist would silently render `NoInstance` with an empty
`method:` field. The iter-22 `MissingClass` pre-pass plausibly rejects
this earlier, but the fallback masks the dependency.

**Outcome:** fix in `24.tidy` — replace with explicit `Internal` or
`expect(...)` to surface registry/index drift on regression.

### [medium-2] `normalize_type_for_lookup` duplicated across two sites

**Path:** `crates/ailang-check/src/mono.rs:687-696` + `:1287-1294`.

The two `normalize_type_for_lookup` call sites use a literal copy of
the body comment rather than extracting a helper. The two sites must
agree byte-identically (per iter-24.3 journal: "Must agree byte-
identically with that site or Phase 3 rewrite cursor produces a mono-
symbol name that differs from the Phase 2 synthesis name"). A
`normalize_free_fn_type_args(env, module_name, metas, subst)` helper
would make the lockstep enforceable by construction.

**Outcome:** fix in `24.tidy` — extract helper.

### [medium-3] Negative-test coverage single-shape (deferred)

**Paths:** `examples/show_no_instance.ail.json` and the broader
NoInstance Show test surface.

The negative E2E covers `f : Int -> Int` (function type without Show
instance). Other NoInstance shapes — user type without instance,
partial-application residual carrying both a missing constraint AND a
NoInstance, polymorphic call with two unsatisfied constraints — are
not pinned. The Show-aware NoInstance arm fires for any
non-prelude-Show type by construction, so the single fixture is
*structurally representative*, but coverage breadth is thin.

**Outcome:** roadmap backlog P3 (not in `24.tidy`). Single-fixture
coverage is acceptable for the architecture-shipping milestone;
broader coverage waits for the corpus-migration milestone (the queued
P2 retirement of `io/print_int|bool|float`) when more downstream
fixtures naturally exercise these shapes.

### [low-1] bench/architect_sweeps.sh noise on pre-milestone-24 DESIGN.md lines

**Path:** `bench/architect_sweeps.sh` matches on `docs/DESIGN.md:50`,
`:449`, `:614`.

All three matched lines predate milestone 24 (text not touched in the
milestone-24 diff). Legitimate references or empirical-addendum
headings, not new history anchors. Sweep noise from prior eras.

**Outcome:** carry-on (not a milestone-24 drift item).

## Bench

- `bench/check.py`: exit 1 with metric identities migrating between
  consecutive runs:
  - Run 1: 2 regressed (`latency.implicit_at_rc.max_us +209%`,
    `bench_list_sum.bump_s +11.9%`), 1 improved
    (`bench_list_sum.gc_over_bump`).
  - Run 2: 2 regressed (`latency.explicit_at_rc.max_us +66%`,
    `bench_list_sum.bump_s +11.9%` again), 1 improved (different
    metric).
  - Pattern: the `latency.*_at_rc.max_us` metric cluster shifts
    identity between runs; `bench_list_sum.bump_s` is the 5th
    consecutive sighting since `audit-mq.tidy`. Same envelope the
    iter-24.3 journal documented as the 8th consecutive observation;
    this audit makes it 9.
- `bench/compile_check.py`: exit 1 with 3-5 regressed metrics across
  runs; metric identity migrates between `build_O0_ms.*` cluster (run
  1) and `check_ms.*` cluster (run 2). All deltas are +5% to +34%
  across runs; tolerance is 20-25% so multiple `check_ms.*` deltas
  cross threshold sporadically. Same migration pattern.
- `bench/cross_lang.py`: exit 0; 25/25 stable.

**Conservative call: baseline pristine for the 9th consecutive audit.**
The metric-identity-migration is itself attribution evidence that the
signal is variance, not regression. If signal were present, the metric
would not migrate between runs at this rate. Ratifying via
`--update-baseline` would pin a snapshot that does not characterise
the actual distribution (since the noise migrates, any single
snapshot is wrong). The right ratification path is the queued P3
"Latency methodology rework — switch from per-run timing to a
histogram-based approach"; until that ships, conservative-call is the
correct response.

No `--update-baseline` invocation this audit.

## Resolution

- `24.tidy` iter to land 5 drift fixes: `[high-1]` (DESIGN.md
  documentation of the three iter-24.3 strengthenings), `[high-2]`
  (codegen import_map-fallback pin), `[high-3]` (FreeFnCall
  constraint-residual push generalisation OR documented invariant),
  `[medium-1]` (replace `unwrap_or_default` with explicit error),
  `[medium-2]` (extract `normalize_free_fn_type_args` helper). Routes
  through `planner` → `implement`.
- `[medium-3]` (negative-test coverage single-shape): roadmap backlog
  P3.
- `[low-1]` (bench sweep noise): carry-on, not actionable.
- Bench: extend lineage to 9th consecutive observation. Pristine
  baseline. Document for future histogram-methodology landing.

## Files touched

- `docs/journals/2026-05-13-audit-24.md` (new, this file)
- `docs/journals/INDEX.md` (one new line)

No production code or test edits in this audit; all recommendations
land in `24.tidy`.
