13 — Change verification & the edge-case matrix

Read this before landing any change to hir (resolution), ty (inference/sigs), or lower (type-directed lowering / codegen). It exists because the corpus gates are necessary but not sufficient: more than once, a change passed every gate and still regressed a real app. This doc catalogues the dimensions a type/resolution/codegen change must survive, and the verification protocol that actually catches the misses.

0. Why this exists — the failure mode

The gate suite (infer / roundtrip / reject / repro / divergences / resolve / s8) byte-matches against the oracle on ~50 curated examples, and build-run go-builds 8 non-FFI examples. That corpus does not exercise every real-world pattern. Concretely, this session:

ChangePassedRegressed (corpus was GREEN)
#164 alias fix v1 (last-segment qualifier heuristic)40/40 oracle + its own testskydeploy control-plane — import Github.Api as Github (alias ≠ last segment)
#164 follow-up (global union_names block)synthetic reproroovo's app — type alias Event collided with the stdlib type Event union in Std.Html.Attributes
#166 fix A (seed annotated params on the lowering path)infer/roundtrip/reject/repro all PASS12-skyvote + 16-skychess — a List (Dict String String) field collapsed to any
#166 fix B (erase row-poly records to any)both reprossame two Std.Db examples — over-erased a concrete Model sharing an instantiation row var

The pattern is always the same: the corpus lacks the specific combination (an import alias that isn't a path segment; a stdlib type name colliding with an app type; a Dict-valued record field in a Std.Db app). A synthetic repro that "matches the shape" is not enough — it reproduces the shape you thought of, not the one the compiler actually mishandles.

Rule: a ty/hir/lower change is not "verified" until it has built the FULL example sweep + at least one real app + the relevant dimensions below. Green corpus gates are a pre-condition, not a conclusion.

1. The edge-case matrix

A change should be reasoned about — and, where plausible, tested — against every dimension it could touch. The bug column cites where each case actually bit us, so the list stays honest rather than theoretical.

D1 — Type-reference resolution (hir, ty::sig)

CaseWhy it's a trapBit us
Same-named type alias in two modulesbare-name keying conflates them#164
Same name as alias in one module, union in anotherunion stays a bare nominal; a same-named alias's expansion can capture it#164 follow-up
Import alias where alias ≠ last path segment (import A.B.C as X)last-segment heuristics break#164 v1 (control-plane)
exposing (T) bare importthe bare name must resolve to the dep's T#164
Stdlib type sharing a name with an app type (Event, Model, Msg)a global set/table keyed by bare name captures the stdlib decl#164 follow-up
Kernel-implicit types (Decoder, Value, Cmd, Route…)synthetic sentinel module — module_name panics if indexed#164 (the DefKind::TypeAlias gate)
Nested module paths (Page.A, Page.Sub)[source] root = "." + dotted module names#164, #166 repros
Cross-module ADT constructor payload (Active PageSub.SubModel)the variant payload's alias must resolve to the other module#164 follow-up
Two bare imports binding the same auto-qualifierE1001 collision pathimport-qualifier rules

Guiding principle: identity of a type comes from HIR's resolver (ResolveResult::type_refsdef_loc(con).module), never a syntactic heuristic or a global bare-name table.

D2 — Record shapes & row polymorphism (ty::infer, lower::goty)

CaseWhy it's a trapBit us
Full closed recordbaseline
Genuine row-poly (`bump r = { rage = … }`)must lower to any + reflective rt.RecordUpdate
Concrete record sharing an instantiation row var (Model in param and result of an annotated fn)looks row-poly to the shared-var heuristic but must stay the nominal _R#166 fix B
Record with an ADT/union fielda dropped field is a nil interface → case panic (not silent)#166
Record with a Dict String String field (map[string]string)must stay concrete; erasing to any breaks a downstream List.head → Ok[…, Maybe Dict]#166 fixes A+B (skyvote)
Record with a parametric-ADT field (Status a)the panic trigger in the reporter's app#166
Record update on a param, returned in a tuple (`( { modelf = v }, Cmd )`)the row leaks → narrow struct drops un-updated fields
Update of ALL fields vs a SUBSETall-fields never drops (README counter — 1 field); subset is the bug surface#166
Nested-TEA sub-model inside a parent AppModel union variantthe real-app shape the minimal repro missed#166

Guiding principle: a record update must preserve the base's full record; a record only erases to any when it is genuinely row-polymorphic AND its fields never flow to a concrete context — the shared-row-var heuristic alone is NOT that proof.

D3 — Annotation state (ty::db vs ty::check)

CaseWhy it's a trap
Fully annotated top-level defthe check path seeds params from the sig; the lowering path does not — the two infer differently (#166 root cause)
Unannotated defbody-only inference; row vars leak / generalise unpredictably
Bare-type-var annotation (msg, a)gives no more than the inferred type; must not be treated as concrete
Head-position alias annotation (view : Renderer Msg)alias must unfold at the head

Open architectural debt (surfaced by #166): ty::check (accept/reject) seeds annotated params from the signature; ty::db::compute_body_types (the lowering feed) does not. So "what type-checks" and "what is emitted" can diverge. Unifying them is the principled fix — but naive seeding perturbs unrelated inference (skyvote). Any fix here MUST run §2 in full.

D4 — Module & dependency structure

CaseBit us
Single module
Internal project modules (src/**)#164/#166
External deps (.skydeps/<slug>/src/**)diagnostic-filename ModuleId shift
Same-named module in project AND a depdiagnostic-filename test
Import alias + exposing in the same filequalifier rules

D5 — App shape (runtime surface)

ShapeDistinctive riskExample
Sky.Cliargparse, Task.run entry07-todo-cli, 20-cli-counter
Sky.Liverecord updates in update, SSE, sessions09-live-counter, 10-live-component, 12-skyvote
Sky.Tuigob serialisation round-trip of the modelroovo's app
Std.DbDict-valued rows, typed decode, SqlValue12-skyvote, 16-skychess, 18-job-queue, 36-composite-server
Nested TEAsub-page models in a parent unionroovo's app

2. Verification protocol (MANDATORY for hir/ty/lower changes)

Run in order; do not claim "verified" until all pass.

  1. Targeted spec proving the change (cargo test -p <crate> <name>).
  2. Corpus gatescargo run -p xtask -- {infer,roundtrip,reject,repro,divergences,resolve,s8}. Pre-condition only.
  3. FULL example sweepscripts/example-sweep.sh builds (and runs the runnable shapes for) every example under examples/ from a wiped slate, and preserves skyshop's FFI cache. Not just build-run's 8. This is the step that catches Std.Db/Dict/FFI regressions (12-skyvote / 16-skychess were invisible to the corpus gates but fail this sweep immediately). Running the corpus gates without this sweep is the exact hole that let #166's fixes look "verified".
  4. Real-app builds — the reporter's repro app (clone the branch they give) + at least one of skydeploy/control-plane, sky-lang.org. These exercise import aliases + large module graphs the corpus lacks.
  5. If the change touches resolution or record types, walk the D1/D2 rows above and add a case to the sweep app (below) for any not covered.

build-run's 8-example limit is a known hole. Until it is widened to FFI examples (requires sky install in CI), step 3 is done by hand / script and is non-optional.

2a. Do not verify by tailing

cargo test -p sky -p project 2>&1 | tail -40 is not a verification. Cargo prints one summary block per test binary, so the tail shows the LAST few binaries and nothing else — a crate whose unit tests live in the first binary is invisible. An embedded-PostgreSQL grill round found this concretely: the ~70 db_cluster.rs unit tests are in -p sky's first binary, and every verdict reached by tailing had verified almost none of them while reporting green.

The same trap applies to go test ./rt/... | tail -N, to -p runs whose crate list you did not choose deliberately, and to any | tail on a command that emits more than one PASS/FAIL summary.

Read the whole output, or ask for a machine-checkable verdict instead:

# the exit status is the verdict — no scrolling, no tail
cargo test -p sky -p project >/tmp/t.log 2>&1; echo "exit=$?"
grep -c '^test result:' /tmp/t.log      # how many binaries actually reported
grep '^test result:' /tmp/t.log         # and what each of them said

If the count of test result: lines is smaller than the number of test binaries the crates own, the run did not cover what you think it did.

3. The sweep/kitchen-sink app (the durable test surface)

The most reliable guard is a single real app that combines the hard patterns, so one sky build exercises most of the matrix. Target contents (a Std.Db + nested-TEA + multi-module Sky.Live app):

Building it clean = most of the matrix in one gate. When a new bug arrives that this app doesn't reproduce, add the missing case to it — that is how the corpus stops being insufficient over time.

4. Companion references