Quartz v5.25

Batch C — Implementation Plan

Branch target: trunk Baseline: b61da0f4 (Batch A+B complete, fixpoint 2281, smoke 4/4 + 22/22) Sprint size: 5 commits. Strict order: C1 → C2 → C3 → C4 → C5. Protocol: quake guard + quake smoke + per-item regression sweep before each commit. No --no-verify, no skipping fixpoint, no dropping the fix-specific backup until the commit lands.


Context

The Batch A+B sprint (Apr 14, 2026) landed 9 of 9 items across two sessions. Five follow-ups were filed in docs/ROADMAP.md during execution — three for real bugs discovered during sprint work, two for polish/guardrails the sprint explicitly deferred. This batch clears those five items in one overnight run, following the exact same execution pattern as A+B:

  • Discrete, well-scoped commits
  • Per-commit quake guard + smoke + regression sweep
  • Honest reporting (regression-lock + ROADMAP filing if a fix doesn’t land)
  • Fix-specific binary backups before each item

Items in strict execution order (easiest → hardest):

#ItemEstRiskHas reproducer?
C1QUAKE-STDERR-AUDIT — migrate 9 remaining 2>/dev/null sites to sh_buffered1hLow (mechanical)N/A (polish)
C2B3-DIRECT-INDEX-FIELD — va[0].y direct access produces QZ0301: Unknown struct: Struct, Int1–2hLow-medYes, in pending test
C3IMPL-NOT-SEND — impl !Send for X explicit opt-out2–3hMedium (parser change)N/A (new feature)
C4POST-RESOLVE-IDENT-ASSERTION — promote unresolved NODE_IDENT at MIR entry to QZ9501 hard error1–2hMedium (may false-positive)N/A (guardrail)
C5B4-UNWRAP-IN-LOOP — step! in while loop miscompile (see separate handoff)2–4hHigh (2 failed attempts already)Yes, minimal 11-line repro

Total estimate: ~8–12 quartz-hours. Fits one overnight run. Ordering is easiest-first so that even if C5 consumes its full budget without landing, 4 commits still reach trunk.


Prime directives (non-negotiable, from CLAUDE.md)

  1. Pick highest-impact, not easiest — but respect the sequencing. Easy-first here is for commit safety, not value ordering.
  2. Design before building; research first — each item has a fix sketch below. Verify each sketch against the current codebase before editing.
  3. Pragmatism ≠ cowardice; shortcuts = cowardice — if a fix turns out bigger than planned, follow the A2/B3/B4 pattern: regression-lock what works, file the deeper gap in ROADMAP, move on.
  4. Work spans sessions — context budget isn’t infinite. If C5 alone exceeds 4h, stop, commit what’s done, file the rest.
  5. Report reality, not optimism — “should work” without verification is a lie. Every commit runs the full per-item verification before shipping.
  6. Holes get filled or filed — every gap discovered during execution goes into ROADMAP immediately.
  7. Delete freely, no compat layers — zero users, zero compat shims.
  8. Binary discipline = source disciplinequake guard mandatory, fix-specific backups mandatory.
  9. Quartz-time estimation — traditional ÷ 4.
  10. Corrections are calibration, not conflict — no rationalizing past a failure. If you can’t fix it, file it.

Cross-cutting findings

  1. Follow the A2/B3/B4 precedent for rm -rf .quartz-cache discipline. Every item starts with: cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-cN-golden && rm -rf .quartz-cache. The incremental cache can hold stale MIR/IR from before a fix landed and produce false reproductions. Three items in Batch A+B (A2, B3, maybe B4) were originally misdiagnosed because of cache staleness.

  2. C3 (IMPL-NOT-SEND) and C4 (POST-RESOLVE-IDENT-ASSERTION) both touch the typechecker registry at similar points. If you land C3 first, C4’s assertion infrastructure may interact with the new neg_impls path. Verify C3 passes the full regression sweep before starting C4.

  3. C1 (QUAKE-STDERR-AUDIT) requires a launcher rebuild, not a compiler rebuild. std/quake.qz already has sh_buffered from A4. The migration is in Quakefile.qz which is compiled fresh by the launcher each run. You do NOT need quake guard for C1 — just verify quake build and quake smoke work after the edit. But DO run guard at the end of C1 for fixpoint discipline.

  4. B4 has its own detailed handoff at docs/handoff/b4-unwrap-in-loop-handoff.md. That doc has the reproducer, the IR analysis, the 2 failed fix attempts, and a 4-phase investigation plan. Treat it as the authoritative source for C5 — this plan just references it.


Per-item design

C1 — QUAKE-STDERR-AUDIT: migrate 2>/dev/null sites in Quakefile.qz

Goal. Every build/link site in Quakefile.qz uses the A4-introduced sh_buffered(cmd, step_name) helper from std/quake.qz instead of 2>/dev/null. On failure, users see the real tool stderr instead of silently failing or getting the old “llc not found” false positive.

Sites to migrate (from the Explore audit during A+B planning):

  • Quakefile.qz:504-505 — Gen1 build’s llc + link commands
  • Quakefile.qz:849 — guard task’s llc invocation
  • Quakefile.qz:935 — guard:source task’s llc invocation
  • Quakefile.qz:1148-1157 — build task’s final link (both -g and non--g paths, both 2>/dev/null and 2>>tmp/build.err)
  • Quakefile.qz:1260 — build:separate task’s link command
  • Quakefile.qz:1301 — compile_qz_tool helper’s link command

Fix pattern. Before:

sh("#{_linker()} -g #{_stack_flag()} tmp/gen1.o -o tmp/gen1#{_mimalloc_flags()} 2>/dev/null")

After:

var rc = sh_buffered("#{_linker()} -g #{_stack_flag()} tmp/gen1.o -o tmp/gen1#{_mimalloc_flags()}", "link-gen1")
if rc != 0
  fail("link-gen1 failed")
end

Note: sh_buffered already prints the command + captured stderr on failure, so the fail() wrapper only needs to abort the build. It does NOT need to re-print the error.

Step names should be short and descriptive: llc-gen1, link-gen1, llc-guard, link-guard, llc-build, link-build, link-build-separate, link-compile-tool, etc. These show up in failure messages.

Test plan. No new spec file. Manual probe: inject a puts("DEBUG_LEAK") into a compile-time path of the compiler, run quake build, verify the error message surfaces the real llc rejection instead of “llc failed”. Revert the injection.

Tensions. None — this is pure polish. The A4 helper does the work; this commit just exercises it.

Steps.

  1. Read the 9 sites in Quakefile.qz to confirm they still match the line numbers above (file may have drifted since the Explore audit).
  2. Migrate each site one at a time. Don’t batch — each edit should be followed by quake build to confirm the Quakefile still parses.
  3. After all 9 sites migrate: inject a debug puts probe into the compiler source, run quake build, verify the error message improved, revert the probe.
  4. rm -rf .quartz-cache && cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-c1-golden.
  5. quake guard — fixpoint should still verify at 2281 (no compiler source changed).
  6. quake smoke — brainfuck 4/4 + expr_eval 22/22.
  7. Update ROADMAP row QUAKE-STDERR-AUDIT to RESOLVED.
  8. Commit: C1: Migrate Quakefile.qz build/link sites to sh_buffered.

Complexity. S (1h). Risk. Low — mechanical, sh_buffered is already tested in A4.


C2 — B3-DIRECT-INDEX-FIELD: va[0].y direct access resolves to wrong struct

Goal. var va: Vec<VA> = vec_new(); va.push(VA{x:10, y:42}); va[0].y == 42 compiles and returns 42 without requiring an intermediate var a = va[0] binding. The pending test direct vec[i].field access reads correct offset in spec/qspec/vec_element_type_spec.qz flips from it_pending to it and passes.

Root cause hypothesis (from the B3 investigation in Session 2):

The error is QZ0301: Unknown struct: Struct, Int. Critical observation: “Struct, Int” is a comma-joined pair of type names from tc_type_name() — “Struct” (TYPE_STRUCT) + “Int” (TYPE_INT). This string shape matches the output of tc_infer_type_param_mapping at typecheck_generics.qz:387-402 where it builds a comma-separated result string from tc_type_name(field_types_vec[i]).

Hypothesis: va[0] is going through a generic UFCS rewrite (maybe va.get(0)Vec$get$2(va, 0) via the intrinsic dispatch). The dispatch machinery uses tc_infer_type_param_mapping to build the generic concrete-type string, and one of the field type IDs returns TYPE_STRUCT with no attached name — so it stringifies to the bare word “Struct”. The “Int” part comes from the index type. The resulting “Struct, Int” string then gets passed to tc_check_field_access as the struct name, which naturally fails to look up.

Investigation starting points.

  1. First, verify the pending test still reproduces on current trunk after rm -rf .quartz-cache. If it’s already fixed (unlikely but possible), flip the it_pending to it and commit as a pure regression-lock update.

  2. Add a debug print in tc_check_field_access at typecheck_generics.qz:520:

    eputs("[DBG-C2] tc_check_field_access struct='#{struct_name}' field='#{field_name}'\n")

    Rebuild, run the failing test, see what struct_name actually is. Confirms the “Struct, Int” hypothesis.

  3. Trace the caller. Grep for tc_check_field_access callers in typecheck_expr_handlers.qz and typecheck_generics.qz. The two callers are lines 1133 and 1311. The struct_name is built by tc_resolve_expr_struct_name (line 485) which calls tc_infer_expr_type_annotation (line 162). Add debug prints at each return point. Find where “Struct, Int” gets assembled.

  4. Likely fix site: tc_find_field_type_name_for_param at typecheck_generics.qz:703 — already has a fallback to tc_type_name(field_types_vec[i]) that returns “Struct” for TYPE_STRUCT. If this helper is what’s producing the broken name, fix it to call tc_infer_expr_type_annotation on the field initializer AST node (same pattern as the STRUCT-GENERIC-ARG-INFERENCE fix did for the struct-init case at line 710-715).

    OR: the fix may be higher up in tc_infer_expr_type_annotation’s NODE_INDEX branch at line 278-290. Check whether it loses the Vec’s element struct name when the result is immediately field-accessed (vs. bound to a var, which works).

Fix sketch (tentative — verify against investigation). In tc_resolve_expr_struct_name for NODE_INDEX receivers, thread the Vec’s element type through via tc_ptype_name(tc, array_type) the same way tc_expr_index at line 443-445 already does. The element struct name is known at the NODE_INDEX typecheck point; it just needs to be preserved in the returned annotation.

Test plan. The pending test already exists at spec/qspec/vec_element_type_spec.qz test 5 (“vec[i].field direct access reads correct offset”). Flip it_pendingit once the fix lands. Add 1-2 more variants:

  • vec[i].method() — direct method call without intermediate binding
  • Nested: va[i].inner.field — field chain through indexed element

Tensions. The tc_resolve_expr_struct_name path is shared with other code — changing its NODE_INDEX handling could affect other field-access call sites. Run the full B-sprint regression set (impl_trait + abstract_trait + traits + hybrid_ufcs_traits + collection_stubs + arity_overload + match_unknown_variant + iterable_trait + iterator_protocol + enum_ctor_in_impl + closure_intrinsic_shadow + send_auto_trait + vec_element_type + unwrap_in_loop + sched_lifecycle) after the fix.

Steps.

  1. cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-c2-golden && rm -rf .quartz-cache.
  2. Re-run the pending test case manually (extract it from the spec file to /tmp/c2_repro.qz). Verify it still fails with QZ0301: Unknown struct: Struct, Int.
  3. Add debug prints to pin the actual “Struct, Int” string source.
  4. Apply fix, rebuild, verify reproducer passes, revert debug prints.
  5. Flip it_pendingit in spec/qspec/vec_element_type_spec.qz test 5. Add 1-2 more variants.
  6. Rebuild, run the spec, verify all tests pass.
  7. quake guard → smoke → full B-sprint regression sweep.
  8. Update ROADMAP row B3-DIRECT-INDEX-FIELD to RESOLVED with commit SHA.
  9. Commit: C2: Fix B3-DIRECT-INDEX-FIELD — vec[i].field reads correct struct offset.

Complexity. M (1–2h). Risk. Low-medium — the investigation path is clear, the fix site is probably one of two known functions, but the “Struct, Int” error string might route through an unexpected call path.


C3 — IMPL-NOT-SEND: explicit impl !Send for X opt-out

Goal. Users can write impl !Send for X end to mark a type as NOT Send even when structural field recursion would auto-derive it. The negative impl always wins — tc_type_is_send consults the neg_impls set FIRST, returns 0 if the type is negatively marked, THEN runs the structural walker. Same semantics for !Sync.

Design.

  • Parser: impl !Send for Counter end! token after impl keyword, followed by trait name. Parse as NODE_IMPL_BLOCK with a new is_negative flag (new AST slot or repurpose int_val).
  • Typecheck registry: new tc_registry.neg_impls: Vec<Vec<String>> — each entry is [trait_name, for_type]. Populated during tc_register_impl_block when the negative flag is set.
  • tc_type_is_send(tc, type_id, struct_name): early-return 0 if (Send, struct_name) is in neg_impls. Same for tc_type_is_sync.
  • Same early-return check in tc_check_trait_bound for the Send/Sync fallthrough path — a negatively-marked type should fail a bound check regardless of structural analysis.

Test plan. New spec spec/qspec/impl_not_send_spec.qz with 5 tests:

  1. struct S { n: Int } impl !Send for S end + def accept<T: Send>(v: T): Int = 0 + accept(S{n:1}) → compile error QZ0200: Type 'S' does not implement trait 'Send' (or similar, whatever B1 produces for negative cases).
  2. Same but without impl !Send → compiles (auto-derivation works per B1).
  3. impl !Sync for S end — Sync analog.
  4. Both !Send and Send for the same type → parser or typecheck error (contradictory).
  5. impl !Send for S end; impl Send for S end → contradictory, error.

Parser changes. In self-hosted/frontend/parser.qz, find the impl block parse (search for NODE_IMPL_BLOCK or ast_impl_block). Insert optional ! handling after the impl keyword:

# After TOK_IMPL:
var is_negative = 0
if ps_check(ps, TOK_BANG)
  ps_advance(ps)
  is_negative = 1
end
# ...parse trait name...

Pass is_negative through to ast_impl_block (add a new argument or use int_val slot).

Typecheck changes in self-hosted/middle/typecheck.qz’s tc_register_impl_block (line ~2895):

if ast_get_int_val(ast_storage, node) == 1  # negative flag
  # Register as negative impl — bypasses method registration
  var entry = vec_new()
  entry.push(trait_name)
  entry.push(for_type)
  tc.registry.neg_impls.push(entry)
  return  # skip the method registration below
end

In tc_type_is_send at typecheck_registry.qz:2746:

# Check negative impl first
if str_byte_len(struct_name) > 0
  if tc_lookup_neg_impl(tc, "Send", struct_name) >= 0
    return 0
  end
end
# ... existing structural walk ...

Add the tc_lookup_neg_impl helper next to tc_lookup_impl.

Tensions.

  • Parser ambiguity: ! is already used as postfix unwrap and as the logical NOT unary prefix. In impl !TraitName position, the ! is unambiguous because impl is a statement-level keyword and what follows is a type/trait expression, not a value expression. Still, verify the lexer treats ! in this position as TOK_BANG and not as something else.
  • Recursive negative impls: what if a type has impl !Send for A and A contains a field B which has impl Send for B end? A should still be negative — the negative impl on A wins over any structural or positive analysis. This is the whole point of the feature.

Steps.

  1. cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-c3-golden && rm -rf .quartz-cache.
  2. Write the 5 test cases in spec/qspec/impl_not_send_spec.qz — 4 positive (Send and Sync, with and without negative impl), 1 negative (contradictory impls). Confirm test 1 currently fails with “compile error expected but got success” (because B1 auto-derives Send from Int fields).
  3. Parser: add ! handling after impl keyword. Use int_val slot on NODE_IMPL_BLOCK to record the negative flag (check first that int_val isn’t already used for something else on impl blocks — if it is, add a new AST slot).
  4. Registry: add neg_impls: Vec<Vec<String>> field to tc_registry in typecheck_util.qz (find the registry struct definition). Initialize to empty vec in tc_new().
  5. tc_register_impl_block: short-circuit when negative, push to neg_impls.
  6. Add tc_lookup_neg_impl(tc, trait_name, concrete_type): Int helper. Returns index if found, -1 if not.
  7. tc_type_is_send / tc_type_is_sync: consult tc_lookup_neg_impl FIRST, return 0 if found.
  8. Rebuild, run the new spec, verify all 5 tests pass.
  9. quake guard → smoke → full regression sweep (especially send_auto_trait_spec — make sure B1’s positive derivation still works for types WITHOUT a negative impl).
  10. Update ROADMAP row IMPL-NOT-SEND to RESOLVED with commit SHA.
  11. Commit: C3: IMPL-NOT-SEND — explicit impl !Send / !Sync opt-out.

Complexity. M (2–3h). Risk. Medium — parser change always carries regression risk; full QSpec sweep is advised but the focused regression set should catch obvious breakage.


C4 — POST-RESOLVE-IDENT-ASSERTION: hard error on unresolved NODE_IDENT at MIR entry

Goal. Any NODE_IDENT reaching MIR lowering without a resolved binding produces a hard QZ9501: internal compiler error: identifier 'X' reached codegen unresolved instead of silently emitting %X undef IR that later llc rejects. Guardrail against the class of bug OPTION-CTOR-IN-IMPL-BODY was in — where a resolver rib omission produced silent codegen garbage.

Design.

  • New helper in self-hosted/backend/mir_lower.qz: mir_assert_ident_resolved(ctx, name, line, col): Void.
  • Called at the top of the NODE_IDENT dispatch in mir_lower_expr (line ~1279).
  • Assertion logic: try mir_ctx_lookup_var(ctx, name), mir_lookup_function(ctx, name), and the arity-mangled variants. If ALL return “not found” AND the name is not a recognized constant / global / intrinsic, emit QZ9501 via the diagnostic module and return TYPE_INT to avoid cascading errors.
  • New error code QZ9501 — “Internal compiler error: identifier X reached codegen unresolved. This indicates a resolver scope-push omission. Please file an issue.”
  • Explain entry in self-hosted/error/explain.qz (or wherever explain entries live).

Risk — the reason this wasn’t done in A2. A2’s plan originally scoped this assertion but I deliberately DIDN’T implement it because OPTION-CTOR-IN-IMPL-BODY was already fixed on trunk and there was no failing test to justify speculative infrastructure (per Directive 5). C4’s justification is different: it’s a prophylactic guardrail against a whole class of future bugs, not a fix for a current bug. The risk is FALSE POSITIVES — the assertion may fire on legitimate code paths where the resolver defers a binding decision:

  1. Closure captures discovered late — when a lambda body references an outer variable, the capture walker may add it to the capture set at a later phase than initial typecheck. At MIR lowering time, is the NODE_IDENT resolved yet?
  2. Open-UFCS receiversv.map(f) where map is a free function in scope, not a method. The NODE_IDENT for map may be treated as unresolved at the initial walk and resolved later via UFCS rewrite.
  3. Macro-expanded gensyms$unwrap generates __macro_N__ names whose bindings come from match pattern destructuring, not scope. Are they “resolved” at MIR entry?

Mitigation: run the FULL QSpec sweep (not just the focused regression set) before committing. If the assertion fires on any currently-passing test, the assertion logic needs refinement — maybe it should check additional fallback paths, or maybe it should warn instead of error for specific known-safe patterns.

Test plan. Two phases:

  1. Negative test (positive for the guardrail): synthesize an AST that produces an unresolved NODE_IDENT by manually constructing a .qz input that would have tripped the old OPTION-CTOR-IN-IMPL-BODY pattern. The assertion should fire with QZ9501. Add as spec/qspec/post_resolve_assertion_spec.qz with assert_compile_error(src, "QZ9501").
  2. Regression sweep: run the full existing QSpec suite, confirm zero new failures. Budget a few hours for debugging any false positives.

Steps.

  1. cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-c4-golden && rm -rf .quartz-cache.
  2. Read self-hosted/backend/mir_lower.qz:1279-1302 (current NODE_IDENT dispatch) to understand all the resolution paths it already tries. Don’t duplicate work — the assertion should fire only when ALL existing lookups would fail.
  3. Read self-hosted/error/explain.qz (or the explain registry) to understand how to register QZ9501.
  4. Add mir_assert_ident_resolved helper. Call it at the top of the NODE_IDENT handler.
  5. Register QZ9501 explain entry.
  6. Write 1 positive test: the synthesized unresolved NODE_IDENT should fire QZ9501.
  7. Rebuild, run positive test, confirm it fires QZ9501.
  8. Run focused regression sweep (B-sprint set). Budget: if ANY test fails because of a false positive, revert and investigate. The assertion logic must be tightened until all existing tests pass.
  9. If focused sweep is clean, run the full QSpec sweep — IMPORTANT, because closure captures / UFCS / generators / async / match bindings are all potential false-positive sources. Give the user the command to run in their terminal: ./self-hosted/bin/quake qspec 2>&1 | tail -50 and have them paste results back.
  10. If full sweep is clean → quake guard → smoke → commit.
  11. If full sweep reveals ANY new failures, document the false-positive pattern, narrow the assertion (e.g. “skip if the NODE_IDENT is inside a closure body that hasn’t finished capture analysis”) or REVERT and file a deeper ROADMAP entry.
  12. Commit: C4: POST-RESOLVE-IDENT-ASSERTION — hard error on unresolved NODE_IDENT at MIR entry.

Complexity. M (1–3h, depending on how many false positives surface). Risk. Medium-high — this is the item most likely to need a narrowed scope or a full revert. If you find yourself whack-a-mole’ing false positives for more than 1h, STOP and file as “partial C4: design needs rework” rather than keep patching.


C5 — B4-UNWRAP-IN-LOOP: step! in while loop miscompile

Goal. var step = Option::Some(10); while i < 3; sum += step!; step = Option::Some(...); i += 1; end returns the correct sum instead of 0. The pending test step! inside while loop with reassignment (B4-UNWRAP-IN-LOOP) in spec/qspec/unwrap_in_loop_spec.qz flips from it_pending to it and passes.

AUTHORITATIVE REFERENCE: docs/handoff/b4-unwrap-in-loop-handoff.md. Read it first. It contains:

  • The 11-line minimal reproducer
  • Full IR analysis showing sum += %v0 where %v0 is a constant 0 defined at function entry
  • The three-condition bug trigger (macro + while loop + subject reassignment)
  • Two failed fix attempts from Session 2 (payloads=0 sentinel, hoist-into-block wrapper)
  • A 4-phase investigation plan (AST dump comparison, MIR context state check, constant-folding pass audit, alternate desugar strategies)
  • Success criteria and a backup binary recipe

Execution: follow the phases in b4-unwrap-in-loop-handoff.md in order. Do Phase 1 (AST dump comparison) first — it’s the most likely to pinpoint the root cause quickly. If Phase 1 doesn’t yield within 1 hour, move to Phase 2. If Phases 1–3 don’t yield within 3 hours total, drop to Phase 4 (alternate desugar — the cleanest is Option C: add an option_unwrap intrinsic and make ! lower to it, bypassing the match entirely).

Do NOT retry the two failed approaches documented in the handoff. They’re documented for a reason.

Steps.

  1. Read docs/handoff/b4-unwrap-in-loop-handoff.md fully. Don’t skim.
  2. cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-c5-golden && rm -rf .quartz-cache.
  3. Verify the reproducer still fails on current trunk (post-C1..C4). If C4’s assertion fires on it, that’s a CLUE — it means the macro-generated match has an unresolved NODE_IDENT for the gensym binding, and the C4 + C5 fixes are related. Note the interaction and proceed.
  4. Phase 1: AST dump comparison (per handoff).
  5. Phase 2+ if needed.
  6. Apply fix, verify reproducer returns 211.
  7. Flip it_pendingit in spec/qspec/unwrap_in_loop_spec.qz.
  8. quake guard → smoke → full B-sprint regression sweep.
  9. Update ROADMAP row B4-UNWRAP-IN-LOOP to RESOLVED with commit SHA and a root-cause summary (so future sessions learn from it).
  10. Commit: C5: Fix B4-UNWRAP-IN-LOOP — $unwrap macro in while loop with reassignment.

Complexity. L (2–4h). Risk. High — two failed attempts already, the bug has defied straightforward AST-shape fixes. Budget accordingly. If C5 burns 4h without a fix, STOP, commit a partial “investigation notes” update to the ROADMAP row with what you learned, and hand off for a future session. Don’t compromise the other 4 commits by starving them of verification time.


Dependency graph

C1 (Quakefile migration) ─── independent, no compiler source change
C2 (B3-DIRECT-INDEX-FIELD) ─── independent, touches typecheck_generics + typecheck_expr_handlers
C3 (IMPL-NOT-SEND)        ─── touches parser + typecheck_registry, uses B1's structural walker
C4 (POST-RESOLVE-ASSERTION) ─ touches mir_lower, may interact with C3's new error paths
C5 (B4-UNWRAP-IN-LOOP)    ─── touches macro_expand or mir_lower, may be EXPOSED by C4's assertion

Order rationale:

  • C1 first: safest warm-up, no compiler source change, builds confidence
  • C2 second: small bug fix with a clear reproducer, bounded scope
  • C3 third: medium-complexity parser work, standalone
  • C4 fourth: needs full QSpec sweep, best done when previous items are stable
  • C5 last: highest uncertainty, gets whatever budget remains

Critical sequencing note: if C4’s assertion fires spuriously on a currently-passing test, that’s potentially a pre-existing bug (the resolver really DID forget to push a rib somewhere). Don’t suppress the assertion to make the test pass — investigate and fix the underlying rib omission instead. But if that investigation exceeds 1 hour, revert C4 and file as “partial: assertion logic needs refinement for X case.”


Verification matrix

ItemProbeNew specRegression sweep
C1Inject debug puts → run quake build → check for real llc error(none)smoke + guard
C2Pending test in vec_element_type_spec.qz flips to passingvec_element_type_spec.qz (flip + add 2 variants)B-sprint set
C3impl_not_send_spec.qz tests 1+3 show negative impl overrides auto-Sendimpl_not_send_spec.qz (5 tests)B-sprint set + send_auto_trait_spec
C4post_resolve_assertion_spec.qz synthetic unresolved NODE_IDENT fires QZ9501post_resolve_assertion_spec.qz (1+ tests)FULL QSpec sweep (mandatory)
C5Pending test in unwrap_in_loop_spec.qz flips to passing(flip existing pending)B-sprint set + iterator_protocol_spec + iterable_trait_spec

Mandatory regression sweep for every commit (the B-sprint set):

impl_trait_spec abstract_trait_spec traits_spec hybrid_ufcs_traits_spec
collection_stubs_spec arity_overload_spec match_unknown_variant_spec
iterable_trait_spec iterator_protocol_spec enum_ctor_in_impl_spec
closure_intrinsic_shadow_spec send_auto_trait_spec vec_element_type_spec
unwrap_in_loop_spec sched_lifecycle_spec

Workflow rules (mandatory per commit)

  1. Snapshot binary: cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-cN-golden.
  2. Clean cache: rm -rf .quartz-cache. CRITICAL — this is the #1 cause of false reproductions.
  3. Read affected files before editing (don’t assume line numbers from this plan — the file may have drifted).
  4. Edit the source.
  5. Rebuild: ./self-hosted/bin/quake build.
  6. Probe test: manually verify the specific fix before running the full spec.
  7. quake guard: fixpoint verified, 2281 ± 30 functions.
  8. quake smoke: brainfuck 4/4, expr_eval 22/22.
  9. New spec: run the item’s new spec file, all tests pass.
  10. Regression sweep: run the B-sprint set above, all green.
  11. Update ROADMAP: mark the row RESOLVED with commit SHA and a one-line summary.
  12. Commit: include commit message body with root cause, fix, verification, and any follow-ups discovered.

On failure: restore from quartz-pre-cN-golden, document what was tried, decide whether to retry or file as a partial commit with notes.

Never use --no-verify, --no-gpg-sign, or skip quake guard. Never run the full qspec suite from Claude Code — give the user the command and have them paste the result back (the 10-min suite hangs in Claude Code’s PTY).


Session-start checklist

cd /Users/mathisto/projects/quartz

# 1. Verify baseline
git log --oneline -3                                    # should show b61da0f4 B2 at top
git status                                              # should be clean
./self-hosted/bin/quake guard:check                     # "Fixpoint stamp valid"
./self-hosted/bin/quake smoke 2>&1 | tail -8            # 4/4 + 22/22

# 2. Read the key docs
cat docs/handoff/batch-c-implementation-plan.md         # this document
cat docs/handoff/b4-unwrap-in-loop-handoff.md           # C5 investigation plan
grep -A 2 "B3-DIRECT-INDEX-FIELD\|B4-UNWRAP-IN-LOOP\|IMPL-NOT-SEND\|QUAKE-STDERR-AUDIT\|POST-RESOLVE" docs/ROADMAP.md | head -60

# 3. Read the A+B sprint results (for context on the workflow + discipline)
grep -A 10 "Batch A + Batch B sprint summary" docs/ROADMAP.md

# 4. Clear cache and start C1
rm -rf .quartz-cache
cp self-hosted/bin/quartz self-hosted/bin/backups/quartz-pre-c1-golden

Success criteria at handoff

  • Minimum viable: 3 of 5 items committed (C1, C2, C3). The Batch A+B sprint’s pattern showed that 3 commits is enough progress for one session even if the harder items stall.
  • Target: 4 of 5 items committed (above + C4 OR C5).
  • Stretch: all 5 items committed.

Each committed item must:

  • Have quake guard fixpoint verified (±30 from 2281)
  • Pass quake smoke (4/4 + 22/22)
  • Pass the B-sprint regression sweep
  • Either have a regression test locking in the fix, OR a clear reason a test isn’t feasible
  • Have an updated ROADMAP row with commit SHA
  • Have any new error codes registered with explain entries

Deferred items (if any) must:

  • Have the ROADMAP row updated with a status note (“investigated 2h, blocked on X”) and enough detail that a future session can pick up from where execution stopped
  • NOT be silently skipped — always document what was attempted

Wake-up report format

At session end, post a summary to the chat mirroring the Batch A+B session 2 wake-up:

  1. What landed: N of 5 items, commit SHAs, fixpoint count.
  2. What didn’t: any deferred items with reason + investigation notes.
  3. Surprises: anything that contradicted the plan (e.g. C2 already fixed on trunk, C4 assertion fired on unexpected paths).
  4. Follow-ups filed: any new ROADMAP entries created during execution.
  5. Next session recommendation: one sentence on what the next logical target is.

Mirrors the structure I used for Batch A+B’s session 2 wake-up, which was the right shape for multi-item overnight sprints.


Estimated quartz-time

Traditional ÷ 4 calibration:

  • C1: 1h (mechanical migration)
  • C2: 1–2h (pinpoint + small fix)
  • C3: 2–3h (parser + typecheck + test file)
  • C4: 1–3h (depends on false positives)
  • C5: 2–4h (budgeted max, likely more)

Total: ~7–13h quartz-time. Overnight-sized for autonomous execution. If the session stalls or runs long on C5, commit what’s done and file the rest — consistent with Directive 4 (work spans sessions).


Prime directives, in one sentence: World-class only, no shortcuts, no silent compromises, honest reports, holes get filled or filed, delete freely, binary discipline = source discipline, quartz-time estimation, corrections are calibration.

Go.