Section 03: Type-Directed Desugar + Validation (DOMINANT)
Goal
See frontmatter and proposal Phases 3-4 for desugaring and validation. This section fixes BUG-04-129.
Phase-placement invariant — ABSOLUTE
The desugar runs in ori_types (type checker), not ori_canon, per typeck.md §EX-17 and `canon.md §2 rows 3-4. AIMS must see the post-desugar pure-reassignment IR.
A canon-phase desugar would let CanExpr::Assign{target: Index/Field} reach AIMS, assigning logical ownership/COW events to the mutation expression instead of the updated reassignment. Canon-phase and ownership-lowering-phase desugars are forbidden.
Implementation Sketch
After type inference resolves the AssignTarget root and receiver types, walk the chain right-to-left. Each index step wraps the inner value in receiver.updated(key: k, value: inner), while each field step wraps it in { ...receiver, field: inner }.
Intermediate reads use index, and the whole target becomes the pure reassignment root = <wrapped>. Parse-time compound expansion composes with this rewrite.
Side-effecting index/key expressions are hoisted to per-step let temporaries so they evaluate exactly once.
Typeck-phase desugar mechanism (arena-grounded — NOT a canon-phase rewrite)
The desugar runs in ori_types and produces a rewritten expression tree that ori_canon::lower::expr (compiler_repo/compiler/ori_canon/src/lower/expr.rs) lowers as the pure-reassignment form, INSTEAD of lowering ExprKind::Assign { target: AssignTarget, value } as-is into CanExpr::Assign (the current Target-only path per canon.md §2 rows 3-4 notes). The ExprArena is append-allocatable during inference — ori_ir::arena::ExprArena::alloc_expr(&mut self, Expr) -> ExprId (compiler_repo/compiler/ori_ir/src/arena/mod.rs:202) mints fresh ExprIds; the desugar consumes the resolved root/step receiver types (already computed by infer_assign_target in compiler_repo/compiler/ori_types/src/infer/expr/operators/assignment.rs) and allocates synthetic nodes for the rewritten target:
- Index step → a synthetic
ExprKind::MethodCall/Callnode resolvingIndexSet::updated(self: receiver, key: k, value: inner)againstreceiver’s inferred type (existing trait-dispatch path resolves the impl;[T]/{K:V}/[T, max N]impls ship in section-01). Non-final reads on the same step resolve viaIndex(read). - Field step → a synthetic struct-spread
ExprKind({ ...receiver, field: inner }) resolvingreceiver’s struct type and asserting field membership. - Root reassignment → the chain collapses to
ExprKind::Assign { target: <root Ident>, value: <wrapped> }(a plain mutable-binding reassignment), soori_canonlowers it through the EXISTING reassignment path and AIMS seesroot = root.updated(...)/root = {...root, f:v}— the post-desugar pure-reassignment IR the phase-placement invariant requires. - Side-effect hoisting → each side-effecting index/key sub-expression (
x[f()]) is bound once to a synthetic per-steplettemporary (allocated viaalloc_expr/alloc_stmt) wrapping the reassignment in a block, sof()evaluates exactly once across read-copy and write-copy (the load-bearingarr[f()] += 1case).
Invariant + grounding:
- The desugar stays in
ori_typespercanon.md §7.1Invariant 5 (unified model) — AIMS never sees aCanExpr::Assign { target: Index/Field }. - A canon-phase or ARC-phase desugar is BANNED — the rejected “allow desugaring in
ori_canon” alternative would let pre-desugar IR reach AIMS and assign ownership/COW events to the mutation expression instead of the reassignment. - Grounding:
typeck.md §EX-17(desugar contract) +canon.md §2 rows 3-4(Target-only → shipped here at the type-checker phase).
03.1 Type-Directed Desugar + IndexSet Resolution + Validation + Diagnostics
Intelligence Reconnaissance
2026-06-01 — feature-mode scaffold; proposal is the research artifact. Desugar contract: read .claude/rules/typeck.md §EX-17 + canon.md §2 rows 3-4 directly (not graph-indexed).
scripts/intel-query.sh file-symbols "ori_canon/src/lower/expr" --repo ori— whereExprKind::Assign{target,value}lowers as-is today (the E4003 path)[ori:ori_canon/src/lower/expr.rs].scripts/intel-query.sh callers "index_assignment_not_supported" --repo ori— the E6080 eval guard to retire[ori:ori_eval/src/interpreter/can_eval/control_flow.rs].- Verify the assignment-checking module against the shipped tree
[ori:ori_types/src/check/bodies/]—ori_types/src/infer/methods/does NOT exist; impl-method body checking lives undercheck/bodies/(provenance: roadmap §15D supersession recorded inindex.md).
Spec References
- Proposal §Desugaring (all cases), §Type Checking Rules, §Error Cases, §Implementation Note (type-directed).
typeck.md §EX-17(the desugar contract:list[i]=v → list.updated(...),state.f=v → {...state, f:v}, Spec §13.6.2 index assignment).canon.md §2 rows 3-4(Target-only → shipped here, type-checker phase).- BUG-04-129 (the symptom this closes).
Tests
tests/spec/expressions/{index_assignment_simple,map_assignment,field_assignment,nested_index_assignment,mixed_chain_assignment,compound_index_assignment}.ori + tests/compile-fail/index_assignment_errors.ori + Rust unit tests in ori_types.
-
TDD-first: failing matrix BEFORE implementation — target shape (
x[i]/x.f/x.f[i]/x[i].f/x.f.g[i].h) × root binding (mutable /$-immutable / parameter / loop var) × operator (=/+=/-=/*=//=/%=/**=) × index expr (literal / variable / side-effectingf()) × backend (interpreter / LLVM). Each cell positive + negative pin. Side-effect cell (load-bearing):arr[f()] += 1with adbg!-countedf()— reject any desugar that double-evaluates. -
Index-step desugar:
recv[k] = v→recv = recv.updated(key: k, value: v)inori_types(resolveIndexSet<Key,Value>onrecv’s type). Hoistkto a temporary when side-effecting.- Ori Tests:
tests/spec/expressions/index_assignment_simple.ori,tests/spec/expressions/map_assignment.ori
- Ori Tests:
-
Field-step desugar:
recv.f = v→recv = { ...recv, f: v }(resolverecv’s struct type; field must exist). No trait needed.- Ori Tests:
tests/spec/expressions/field_assignment.ori
- Ori Tests:
-
Nested + mixed chains: right-to-left wrapping per proposal §Mixed Chains; intermediate reads via
index. Covergrid[x][y][z]=v,state.items[i]=x,list[i].name=x,game.levels[i].enemies[j].hp=0.- Ori Tests:
tests/spec/expressions/nested_index_assignment.ori,tests/spec/expressions/mixed_chain_assignment.ori
- Ori Tests:
-
Compound assignment: verify two-step desugar (
list[i] += 1→list[i] = list[i] + 1→list = list.updated(...)) for all compound ops × all target forms; single evaluation of the target/index expressions.- Ori Tests:
tests/spec/expressions/compound_index_assignment.ori
- Ori Tests:
-
Validation + diagnostics (proposal §Type Checking Rules + §Error Cases): root-binding mutability (reject
$/param/loop-var); field membership;Index(non-final) +IndexSet(final) resolution with agreeing Value types; RHS assignability; key-type match. Allocate E-codes inori_diagnostic(structured construction, neverformat!strings, perdiagnostic.md); each error gets a#compile_failpin.- Root-mutability gate — parameter coverage (load-bearing): the existing
infer_assign_target(compiler_repo/compiler/ori_types/src/infer/expr/operators/assignment.rs) rejects ONLYengine.env().is_mutable(name) == Some(false).is_mutable(compiler_repo/compiler/ori_types/src/infer/env/mod.rs:121) returnsNonefor PARAMETERS — they bind via plainenv.bind()(mutable: Noneperenv/mod.rs:99; e.g.check/bodies/functions.rs:58,infer/expr/lambdas.rs:49), NOTbind_with_mutability. So== Some(false)is FALSE for a parameter root and reassignment through it slips past the immutable-root rejection silently. LOOP VARIABLES, by contrast, bind viabind_pattern→bind_with_mutability(*name, ty, Mutability::Immutable)(infer/expr/sequences.rs:344, reached fromcontrol_flow/loops.rs:88) →is_mutable == Some(false), so a loop-var root is ALREADY rejected by the existing== Some(false)check (NOT a gap; verified against the shipped tree). The mission requires “Root binding must be mutable (non-$, not a parameter, not a loop variable)”. This work item MUST reject any root whoseis_mutable(name) != Some(true)(i.e. reject BOTHSome(false)— thelet $x+ loop-var cases already caught — ANDNone— the parameter case currently slipping past) for the assignment-target path — the validation MUST NOT silently pass onis_mutable == None. - WHERE:
ori_types/src/check/(assignment-target checking; impl-method/desugar entry — verify exact module against the shipped tree, NOT a guessed path) +ori_types/src/infer/expr/operators/assignment.rs(theis_mutablegate) +ori_diagnostic/src/error_code/mod.rs. - Ori Tests:
tests/compile-fail/index_assignment_errors.ori(immutable$-root, parameter root, loop-var root, str-no-IndexSet, no-field, value-mismatch, key-mismatch). The parameter-root pin fails today (silent pass onis_mutable == None) and MUST reject after this item. The loop-var-root pin ALREADY rejects today (is_mutable == Some(false)viabind_with_mutability(Immutable)) — it is a regression-guard that this item must keep green, NOT a currently-failing case.
- Root-mutability gate — parameter coverage (load-bearing): the existing
-
Dual-execution parity + BUG-04-129 close: interpreter == LLVM for every desugared form;
ori build repro.ori+ori run repro.oriboth correct (E4003 + E6080 gone). Remove the now-unreachable E4003 (ori_arc) + E6080 (ori_eval) guards OR convert to internal-invariant assertions (no valid program reaches them).ORI_CHECK_LEAKS=1zero leaks. -
Verify: full matrix green debug + release, both backends;
./test-all.sh.
Verification Layers (per .claude/rules/layer-coverage.md §1 — addressed-or-justified)
Each pin below names a concrete probe + expected reading; every applicable matrix layer is COVERED or JUSTIFIED-N/A. These are the verifiable criteria the blind-spot review folded in (the deliverable’s L9/L10/L12 + guard-removal + phase-placement were unencoded).
- L1 Parse —
N/A: parserAssignTargetshipped in section-02; section-03 consumes it, adds no grammar surface. - L2 Typecheck — COVERED: desugar + validation land here. Probe: Rust unit tests in
ori_typesexercisinginfer_assign_target+ the synthetic-node rewrite (index/field/nested/mixed/compound; immutable/$/param/loop-var rejection);tests/compile-fail/index_assignment_errors.ori. Expected: each form desugars; each error rejects with its E-code. - L3 Canonicalize — COVERED: assert
ori_canon::lower::expr(compiler_repo/compiler/ori_canon/src/lower/expr.rs) lowers the desugared root-reassignment through the existing reassignment path. Phase-placement negative pin (load-bearing): NOCanExpr::Assign { target: Index/Field }and NO residualAssignTargetreachesori_canon/AIMS post-desugar (the cross-phase contract this section satisfies percanon.md §7.1Invariant 5). Probe (load-bearing automated pin): a Rust canon-phase unit test (ori_canontests) asserting the canonical IR of each desugared form carries onlyCanExpr::Assign { target: Ident }+updated/spread call nodes — NOCanExpr::Assign { target: Index/Field }and NO residualAssignTargetsurvive (there is noORI_DUMP_AFTER_CANONflag; the assertion is a test, not a dump). Manual inspection:ORI_DUMP_AFTER_TYPECK=1on each desugared form confirms the post-desugar typed IR is the rewritten reassignment, not anAssignTarget. The E4003ori_arcguard (compiler/ori_arc/src/lower/control_flow/mod.rs, theCanExpr::Index/CanExpr::Fieldarms) is never hit by a valid program. Expected: zero index/field assignment targets survive to ARC lowering. - L4 AIMS — COVERED: AIMS sees pure reassignment and freezes the correct COW/ownership facts. Probe: inspect the post-AIMS artifact on a
001_100_doors-shaped repro. Expected: no spurious ownership events on the mutation expression; static-unique and dynamic-sharing cases are distinguished without a physical counter assumption. - L5 Interpreter (eval) — COVERED:
ori runof every desugared form. Probe:./target/debug/ori test --verboseovertests/spec/expressions/{index_assignment_simple,map_assignment,field_assignment,nested_index_assignment,mixed_chain_assignment,compound_index_assignment}.ori. Expected: correct value; E6080index_assignment_not_supportedguard (compiler/ori_eval/src/interpreter/can_eval/control_flow.rs) unreachable. - L6 LLVM codegen — debug — COVERED:
ori test --backend=llvm(debug) over the spec corpus. Expected: identical results to eval. - L7 LLVM codegen — release — COVERED:
./target/release/ori test --backend=llvm(FastISel path differs from debug). Expected: green. - L8 AOT — COVERED:
ori build repro.orithen run the binary. Expected: correct value and no spurious E4003; this removes BUG-04-129’s AOT symptom. - L9 Dual-execution parity — COVERED: interpreter ≡ LLVM for every desugared form. Probe:
dual-exec-verify.sh --test-only tests/spec/expressions/over the index/field-assignment corpus + thearr[f()] += 1eval-once count-pin. Expected: identical observable behavior;f()evaluates exactly once on both backends. - L10 Resource check — COVERED: run exact logical ownership-event parity plus each admitted executor’s leak/resource probe over the desugared-form corpus.
ORI_CHECK_LEAKS=1andORI_TRACE_RC=1remain the current compiled ARC adapter probes, not universal AIMS evidence. - L11 Spec conformance — COVERED:
tests/spec/expressions/**index/field-assignment cases pin Spec §13.6.1/§13.6.2 behavior (corpus authored by this section; owned by section-05 per the documented FILE_CONTENTION split). - L12 Production entry point — COVERED: run
ori build repro.oriandori run repro.orion BUG-04-129’s real index/field-assignment reproduction. Require the correct value with neither E4003 nor E6080; this is a production CLI gate, not a seam-injected unit test. - Guard removal-or-convert pin: the E4003 (
ori_arc) + E6080 (ori_eval) guards are REMOVED (now unreachable) OR converted to internal-invariant assertions per the overview’s defense-in-depth decision — assert no valid post-desugar program reaches either guard (the L3 phase-placement negative pin proves unreachability). ori_typesRust desugar unit tests pin: the synthetic-node rewrite has Rust-level unit coverage inori_types(not only.orispec tests) asserting the producedupdated/spread/reassignment node shape per target form.