Operation Piezoelectric Effects — Implementation Plan
Living plan document for the algebraic effects epic in Quartz.
Epic name: Operation Piezoelectric Effects (commit tag
[piezo], casually “Piezo”). Named after the piezoelectric effect — the physical property of quartz crystals that converts mechanical stress into electric charge. Curie brothers, 1880.
Status
- Phase: 0 (design) ✅ COMPLETE 2026-04-18 → 1 (implementation) ready to begin
- Last updated: 2026-04-18
- Governing doc:
docs/EFFECTS.md(stubbed 2026-04-18) - Companion docs:
docs/research/NOTATION_PRIMER.md,docs/research/EFFECT_SYSTEMS_NOTES.md,docs/research/EFFECTS_LLVM_COMPILATION_MEMO.md - Session handoff:
docs/handoff/operation-piezoelectric-effects-phase-0.md
Design Philosophy: Invisible by Default, Rich When Wielded
Effects are pervasive internally — the language is effect-typed all the way down. But they are optional externally. The user engages at three levels, all first-class:
| Level | What the user writes | Example |
|---|---|---|
| Invisible | No can annotations. No try. No with. Code reads as if effects don’t exist. Prelude-installed default handlers do the right thing. | def main(); puts("hello"); end |
| Implicit | Effect rows inferred by the typechecker; visible in LSP hover, quartz doc, error messages. Code stays clean. | def greet(name) = puts("hi, " + name) — inferred can StdIo, not annotated |
| Explicit | Full can rows, hand-crafted with handlers, try markers on propagation points, effect-polymorphic combinators, sandboxing. | def sandbox_app() can FileIo = ... composed with with net_denied do ... end |
The substrate is the same at all levels. The surface is progressive disclosure.
Non-negotiables
- Trivial programs have zero effect ceremony.
canannotations are never required for correctness — inference fills them in. They document or constrain.- The rich explicit features (
with catch, row polymorphism,trymarkers, effect-polymorphicmap) are first-class, reachable from any scope — not gated by mode switches, pragmas, or “advanced mode.” - Writing invisibly never locks you out of wielding — you can drop into explicit at any expression boundary.
- Effects are the substrate, not an “advanced feature layer.” Rules about visibility are about noise, not capability.
Philosophy alignment (cross-cutting)
This matches Quartz’s existing pattern:
- Const by default,
varto opt in - Infer types everywhere, annotate at boundaries
- Types rich at compile time, erased at runtime
- Implicit
itin blocks, explicit parameter names when you want them - Open UFCS: three levels of dispatch (impl method → trait method → open UFCS)
- Async colorless — no coloring tax
- Trait auto-satisfaction (no
impl Container for Queueneeded when members already satisfy) - Drop/RAII automatic; custom Drop for cleanup
Effects take the next step: error handling, Io, state, diagnostics, allocator, async — all substrate. You wield them when you want to.
Filter for downstream decisions
When picking between options in the remaining design space, the tie-break is:
- Does this work with zero effect annotations in simple programs? (If no, redesign.)
- If the user wants to be explicit, is the rich form at hand?
- Are both paths first-class, or is one second-class?
What’s committed (decisions made in planning so far)
Syntax
cankeyword for effect annotations (not!— Quartz already uses!as Option unwrap)- Effect rows with comma-separated labels:
can Io, Throws<E> with ... do ... endfor handler installationeffect ... endblocks for effect declaration- Inference by default; annotations at API boundaries only
Type system
- Row polymorphism, Rémy-style unification
- HM-extended inference including effect row variables
- Effects are part of function types:
(args) → ε return(Koka convention) - Every function has an effect row (empty ⇒ pure, equivalent to
total)
Migration strategy
- Stdlib-first pilot sequence:
log→random/clock→ QSpec capture helpers Result: kept as a type; the convention of returningResultfor control-flow errors migrates toThrows. Result stays for error-as-data use cases.opt!(Option unwrap): may become sugar forthrow(None); decision deferred- Compiler dogfooding: Phase 5, not Phase 1. Prove machinery on stdlib first.
Soundness
- Effect system is load-bearing, not decorative
- No unchecked-exception escape hatches
- Requires operational semantics + soundness proof (feeds the existing formal spec work)
Main and defaults (§8.5 of future EFFECTS.md)
- Prelude installs default handlers for Throws, Panic, Log, FileIo, NetIo, ProcIo, StdIo, Clock, Random, Env (full v6.0 blessed Phase-1 set)
- Trivial
def main()programs have no effect ceremony - Custom handlers are strictly additive overrides
Default handler behaviors (committed 2026-04-18)
Panic effect — confirmed as a first-class effect, not a separate language builtin. Default handler prints a structured error message + stack trace to stderr, flushes all buffered output, and exits with code 101. Custom handlers unlock: structured JSON crash reports, debugger hooks (trigger int3 on panic), test-harness capture (QSpec installs a handler so a panicking test doesn’t kill the runner), cleanup-before-die (flush logs, close sockets, delete temp files), telemetry emission.
StdIo effect buffering — Option D (line-buffered for TTY, block-buffered for redirect/pipe, auto-flush on panic via the Panic default handler). Rationale:
- TTY → line buffering is what users expect from a REPL/CLI; each line appears as written
- Pipe/file → block buffering gives libc-level throughput when output is redirected
- Auto-flush on panic is the killer missing guarantee in libc-style programs (last writes lost on crash); the
Panichandler +StdIohandler composition provides it for free - Custom
StdIohandlers enable: in-memory capture for tests, unbuffered mode for debugging, structured emit (JSON per write) for log aggregators
Both behaviors are example cases of “invisible default, rich when wielded” — the user who writes puts("hi") gets sensible behavior with zero config; the user who wants custom behavior installs a handler.
Error-handling surface cleanup (committed 2026-04-18)
Rather than stack Throws on top of the existing ! / $try / Option / Result / Panic surface, unify the whole thing into four clear tiers:
| Tier | Semantics | Catchable? | Typed? | Form |
|---|---|---|---|---|
| Assertion | ”I proved this; crash if wrong” | No | No | x! (panics) |
| Control flow (effect) | “Empty/Err is a legit outcome for my caller” | Yes | can Throws<E> | try x, throw(e) |
| Error as data | ”I want the error value to store/pass/serialize” | N/A (data) | Return type | Result<T, E> |
| Panic | ”Unrecoverable (OOM, invariant)“ | No | No | panic(msg) |
Per user-intent mapping (each intent gets exactly one canonical form):
| Intent | Canonical form | Notes |
|---|---|---|
| Test | x.some? / x.ok? | Unchanged |
| Narrow | x is Some(v) | Unchanged |
| Destructure | match, if let | Unchanged |
| Assert | x! (panics, uncatchable) | Unchanged |
| Propagate | try x (prefix keyword, optional per philosophy) | Replaces $try(x) macro |
| Default | x ?? default, x.unwrap_or(d) | Both kept (ergonomic chaining) |
| Chain-through | a?.b?.c | Unchanged |
| Transform | x.map(f), x.flat_map(f) | Unchanged |
| Reify (effect→Result) | reify { ... } block | New |
| Convert (Result→effect) | try res | Same form as propagate |
Key decisions:
tryis optional sugar, not mandatory (per philosophy: invisible default, explicit when wielded). Implicit propagation still happens through effect-row inference;tryis for when the user wants the propagation visible to a reader.!stays as assertion (panics, uncatchable, not effect-typed). Aligns with Rust.unwrap(). Three distinct intents keep three distinct forms (!/try/match).$try(x)macro deleted in favor oftrykeyword.- Option propagation uses unified Throws with an
Emptypayload (not a separatePartialeffect). Simpler substrate; catchable via standardwith catchhandlers. throwis an expression (Never type), consistent withreturnandpanic.- Error messages grade down by engagement level: invisible-level users see “uncaught throw, add
can Throws<X>or install a handler”; explicit-level users see full row-diff vocabulary.
Compilation model
- Committed (2026-04-18): evidence-passing (Koka, Leijen 2017 style)
- Single-shot direct-call on the fast path; multi-shot exists as a slower escape hatch
- Rejected alternatives:
- Stack-copying / fibers (OCaml 5) — uniform but heavy per-op cost; per-target stack-switch assembly burden
- Capability-passing (Effekt) — highest type-theory load; duplicates what evidence already gives us
- Rationale: every effect on the Quartz roadmap (Throws, Io, Log, Clock, Random, State, Reader, Async) is single-shot. Ndet (multi-shot) is the only Phase 4 candidate and explicitly deferred indefinitely as nice-to-have. Evidence-passing’s single-shot fast path wins the 95% case at a fraction of the impl burden of fibers.
Open decisions (must close before Phase 1 begins)
Compilation model— ✅ CLOSED 2026-04-18: evidence-passing. See Decision log.Scoped / duplicate labels— ✅ CLOSED 2026-04-18: allow duplicates with lexical shadowing (Model B, Koka-style). Inner handlers shadow outer;throwbinds to innermost matching handler; re-throw propagates outward. Matches Koka, Effekt, OCaml 5, Eff. See Decision log.Initial effect set— ✅ CLOSED 2026-04-18: 14 blessed atomic effects + 1 row alias. Phase 1 pilot: Log first, Parse second. See Decision log for full set.— ✅ CLOSED 2026-04-18:opt!sugar!stays as assertion / panic path, NOT effect-typed. See Error-handling surface cleanup below and Decision log.Effect row subtyping— ✅ CLOSED 2026-04-18 (see #8 below): flat row equality + tail polymorphism. Subtyping rejected.Error message quality bar— ✅ CLOSED 2026-04-18: six operational commitments + engagement-level detection heuristic. See Decision log and Phase 1 § “Error messages.”— ✅ CLOSED 2026-04-18: hybrid. Atomic labelsIogranularityFileIo,NetIo,ProcIo,StdIo,Clock,Random;Iois a row alias for their union. Stdlib signatures default to atomic (information-dense);Ioalias available as escape hatch. See Decision log.Effect row subtyping (Decision #5)— ✅ CLOSED 2026-04-18 as fallout of hybrid: flat row equality + tail polymorphism (Koka-style). Row subtyping rejected (HM inference interaction, error-message complexity, rejected by Koka for the same reasons). Hybrid model works cleanly with flat rows via tail-polymorphic row variablescan Io | ε.
Phase 0 — Research completion
Goal: understand the design space deeply enough to make Phase 1 decisions irrevocable.
Entry criteria (met):
- Notation primer complete
- Abstract + §1 of Leijen 2014 read and unpacked
Deliverables:
- Read Leijen 2014 §2–6 (rest of the paper) — deferred, largely subsumed by 2017 paper
- ✅ Read Leijen 2017 “Type Directed Compilation of Row-Typed Algebraic Effects” (2026-04-18 — validates type system + scoped labels + inference rules)
- ✅ Read Xie & Leijen 2020 “Effect Handlers in Haskell, Evidently” (2026-04-18 — validates evidence-passing compilation; surfaced three-kind operation taxonomy as critical design insight)
- Skim Rémy row polymorphism (for unification details) — deferred, papers cover enough
- Skim Effekt (Brachthäuser) for capability-passing contrast — deferred, capability-passing rejected
- Skim OCaml 5 effects RFC for the alternative compilation model — deferred, fibers rejected
- ✅ Populate
docs/research/EFFECT_SYSTEMS_NOTES.mdwith per-paper notes (Leijen 2017 + Xie-Leijen 2020 entries) - ✅ Close each open decision above with a position in the decision log (10/10 decisions closed)
- ✅ NEW: LLVM-specific compilation memo —
docs/research/EFFECTS_LLVM_COMPILATION_MEMO.md
Exit criteria:
- ✅ Compilation model chosen, with rationale (evidence-passing, Xie-Leijen 2020; rationale in both papers + LLVM memo)
- ✅ Initial effect set curated (14 atomic effects +
Ioalias; Ndet + Yield deferred) - ✅ Syntax locked (
can,effect...end,with...do...end,try,reify {},throw,resume) - ✅
docs/EFFECTS.mdstubbed with full phase structure
Phase 0 complete 2026-04-18. All exit criteria met. Phase 1 can begin.
Estimate: 3–5 quartz-days · 4–6 sessions
Phase 1 — Throws pilot (end-to-end)
Goal: working Throws effect, one stdlib module migrated, default main handler, real error messages. Proves the full machinery on the simplest useful effect.
Entry: Phase 0 exit met.
Parser (self-hosted/frontend/parser.qz)
cankeyword in function signatureseffect ... enddeclaration blockwith ... do ... endhandler block- Effect rows in generic parameter lists
Type system (self-hosted/middle/typecheck*.qz)
Rowrepresentation: labels + optional tail variable- Row registry in
TcRegistry - Row unification (Rémy-style, no duplicate labels yet)
- Row substitution, occurs check, pretty-print
- Effect row on function types (extend structured function type representation)
- Call-site effect propagation: inherit callee’s row into caller’s
- Effect subtraction at handler boundaries
- Effect-op call: adds op’s label to caller’s row
- Effect polymorphism in generalization/instantiation
- Soundness story for let-polymorphism + effects (value restriction replacement)
MIR (self-hosted/backend/mir*.qz)
- Effect-op primitive
- Handler install / uninstall primitives
- Effect row metadata on MIR function types
Codegen (self-hosted/backend/codegen*.qz)
- Evidence parameter generation
- Handler dispatch at effect-op sites
- Stack-allocated handler structs
Stdlib
std/logmodule migrated (simplest effect) ORstd/parse(clear Throws use case)- Default main-handler installation via prelude injection
Testing
- QSpec specs for Throws: throw, catch, row in signatures, inference
- Smoke programs (
brainfuck.qz,style_demo.qz) continue to pass - Fixpoint (gen1 == gen2 byte-identical) holds through the transition
Error messages (quality bar — six commitments)
Every Phase 1 effect error message must:
- Point at the offending source location — file:line:col, rustc-style caret underlining, with the effect-producing expression clearly marked.
- Name the effect in plain English first, row-syntax second — “this can fail” before “adds
Throws<ParseError>to the row.” - Suggest a concrete fix — never just “error: row mismatch.” Always include either (a) the signature change to make, or (b) the handler to wrap with. Suggested code is compilable as shown.
- Grade by engagement level. See detection heuristic below.
- Never leak implementation vocabulary — no “unification failure,” no “ρ₁₂₇,” no “row variable tail,” no “substitution.” Use “row,” “handler,” “catches,” “throws,” “effect.”
- Runtime uncaught-throw reaches
mainformatted with a stack trace and a remediation hint — not a barepanic: ....
Engagement-level detection (committed 2026-04-18)
User is classified per-error-site by the typechecker using this heuristic (first match wins):
| Trigger | Level | Message form |
|---|---|---|
User code uses row variables (can X | ε), explicit handler composition, or effect-polymorphic combinators in the enclosing scope | Expert | Terse row-diff. Zero teaching. No sidebars. expected row {FileIo, Throws<E>}, found {FileIo}. |
Enclosing function has can ... annotation OR an enclosing with ... do ... end block | Explicit | Full row-diff + concrete fix shown as code. Skip the conceptual teaching paragraph. |
No can annotations anywhere in enclosing function, no with in scope | Invisible / learning | Plain-English description first (“this can fail”), then the concrete fix, then why (one sentence on what an effect row is). Show both the signature-widening path AND the handler-wrapping path. |
Filter principle (user quote, 2026-04-18): “If we detect that they’re big enough to be on the ride, we can allow them in on some of the sausage making. Otherwise, we insulate.”
Insulation means: the user who has never written can in their life never sees the words “row,” “label,” or “unification” in an error message. They see “this can fail — handle it here or declare it on your function signature.” Period.
Error-case coverage (Phase 1 must ship)
- Uncaught throw — call-site view at Invisible level (full teaching) and Explicit level (row-diff).
- Row mismatch at call site — show caller row, callee row, specific missing label(s), concrete fix.
- Handler catches wrong label — point at both the handler declaration and the throw site, show combined-handler fix.
- Inference ambiguity — when a function’s row can’t be determined, name the ambiguous binding in plain English, suggest an annotation.
- Effect in pure-context — if a
Fn(Int): Int(no row) binding receives a function that has effects, explain the row mismatch at binding type and at the lambda, not just at the call.
Phase 1 kill criterion
If any of these messages cannot be produced without a major type-system rewrite, Phase 1 is at risk. The messages ARE the product — not decoration.
Exit criteria:
- All Phase 1 QSpec specs green
- Fixpoint verified
- Smoke programs pass
- Error-message quality bar met (no row-unification jargon leaks)
- Default main handler works end-to-end
Estimate: 5–7 quartz-days · 7–10 sessions
Phase 1 risks
- Row unification edge cases harder than expected
- Evidence-passing perf worse than direct-call baseline
- Interaction with existing generic / HM inference surfaces latent bugs
- Error messages require major type-system introspection to make teaching-grade
Phase 1 kill criteria
-
2x estimated effort with no end in sight
- Evidence-passing > 10% slower than direct-call baseline
- Error messages can’t be made teaching-grade without major type-system rework
Phase 2 — State and Reader
Goal: multi-effect composition works. Inference stress-tested with multiple labels per row.
Entry: Phase 1 exit met.
Deliverables:
State<S>effect:get/setoperationsReader<R>effect:ask/localoperations- Rémy merging across multi-effect rows
- Pilot: world-class CLI / config system — Ruby-Gemfile-quality ergonomics (explicitly NOT Python-style). Replaces ad-hoc
argvparsing across examples andquake. UsesReader<Config>for config,State<ArgState>or similar for parse state. Dogfoods the new effect machinery AND delivers immediate DX win for Quake tooling. - Handler composition:
with state(0), reader(config) do ... end
Exit:
- Multi-label rows unify cleanly
- Inference produces predictable types (no surprising polymorphism collapse)
- QSpec covers state + reader + composition
- Pilot subsystem works
Estimate: 3–5 quartz-days
Phase 3 — Async-as-effect migration
Goal: migrate colorless async from its current special-case MIR lowering to the general effect machinery. Proves effects are Quartz’s unifying mechanism.
Entry: Phases 1–2 exit met.
Deliverables:
Asynceffect:await,suspend,spawnoperations- Handler = scheduler
- Migrate existing
$pollstate-machine lowering to effect-lowering - All existing async code continues to work (no user-visible regression)
- Effect-polymorphic async combinators (
go,await)
Exit:
- All existing async QSpec passes unchanged
- Special-case async MIR lowering deleted (Prime Directive 7, no compat shim)
- Effect-polymorphic map / filter / etc. work with async functions
- Scheduler intrinsics integrate cleanly with handler discharge
Estimate: 7–10 quartz-days · 1.5–2 calendar weeks. Highest risk. Load-bearing.
Phase 3 kill criteria
- Migration requires > 2x the LOC of current async impl → redesign
- Scheduler performance regresses > 10% → redesign
- Effect-polymorphic call sites can’t monomorphize cleanly → reconsider compilation model
Phase 4 — Non-determinism and generators
Goal: multi-shot handlers. The hard case — handler resumes continuation more than once.
Entry: Phases 1–3 exit met.
Deliverables:
Ndeteffect:chooseoperation- Generator syntax via
Yield<T>effect - Continuation-capture machinery where evidence-passing isn’t enough
- Backtracking handler example
- Effect-polymorphic generators (the Q10 benchmark question comes home to roost)
Exit:
- Multi-shot handlers work on non-trivial programs
- Generators compose with other effects
- Performance acceptable (continuation capture only when actually needed)
Estimate: 5–7 quartz-days
Phase 5 — Compiler dogfooding
Goal: the payoff. Rewrite compiler subsystems using effects, dramatically reducing plumbing.
Entry: Phases 1–4 exit met. Effect machinery stable and battle-tested.
Deliverables (rough order, each a sub-phase):
- Diagnostics as effect (biggest single win, ~15% LOC reduction in typecheck + codegen)
- Type context / scope state as
State<TcCtx> - LLVM IR emission as
Emiteffect - Module resolution with
Fseffect (testability win) - Symbol / intern tables as effects
Exit:
- Compiler LOC reduced measurably (target: 10–15%)
- Compilation performance not regressed
- Fixpoint holds through each migration
- Error-reporting pipeline simpler / more readable
Estimate: 2–3 quartz-weeks · 20–30 sessions. Large, but each sub-migration is contained.
Cross-cutting: teaching + docs
Ships with Phase 1 (non-negotiable):
docs/EFFECTS.mdtutorial chapter- 10–15 curated examples in
examples/effects/ - CLAUDE.md section on effect idioms (teaching material for future contributors, human or LLM)
- Effect-system error-message style guide
Accumulates through Phases 2–4:
- More examples per new effect
- Pattern cookbook (common handler patterns, common anti-patterns)
- Migration guide for existing code
Ships with Phase 5:
- Retrospective on the dogfooding migration
- Performance benchmarks, before/after
- Compiler architecture doc updated
Project-level kill criteria
Conditions that trigger abandonment or major redesign of the effects direction:
- Phase 3 async migration requires > 2x current async impl
- Effect polymorphism inference fails without pervasive annotations
- Evidence-passing > 10% slower than direct-call baseline
- Linear/move-semantics interaction forces unacceptable handler restrictions
- Error messages can’t be made teaching-grade in Phase 1
Estimation summary (quartz-time)
| Phase | Traditional | Quartz-time | Sessions |
|---|---|---|---|
| 0 — Research | 1–2 weeks | 3–5 days | 4–6 |
| 1 — Throws pilot | 3–4 weeks | 5–7 days | 7–10 |
| 2 — State/Reader | 2 weeks | 3–5 days | 4–6 |
| 3 — Async migration | 4–6 weeks | 7–10 days | 10–14 |
| 4 — Multi-shot | 3 weeks | 5–7 days | 6–10 |
| 5 — Compiler dogfooding | 8–12 weeks | 2–3 weeks | 20–30 |
| Total | 21–29 weeks | 5–8 weeks | ~50–75 sessions |
Phases 1 and 3 are load-bearing. If both succeed, effects become Quartz’s organizing principle. If Phase 1 succeeds but Phase 3 doesn’t migrate cleanly, keep Phases 1–2 as a new feature and defer the unification story.
Decision log
Append-only. New entries at the bottom, never rewrite history.
- 2026-04-17 —
canchosen over!(collision with Option unwrap). Strongman revisited after pushback; see earlier planning session. - 2026-04-17 — Effect inference is a Phase 1 hard requirement, not a later addition. Based on Koka’s empirical track record.
- 2026-04-17 — Stdlib-first migration order committed. Compiler dogfooding explicitly deferred to Phase 5.
- 2026-04-17 — Systems-language positioning: Quartz qualifies (LLVM IR, self-hosting, manual memory, cross-platform, no GC). Koka does not (C backend, Perceus RC). Phrase “first systems language with first-class algebraic effects” cleared for §1 of EFFECTS.md.
- 2026-04-17 — §8.5 “Main and the Default Handler Stack” added as a distinctive Quartz design element. Prelude-installed default handlers mean trivial
def main()has zero effect ceremony. - 2026-04-17 — Evidence-passing is the leading compilation model. Explicit commit deferred until Leijen 2017 is read.
- 2026-04-18 — Compilation model committed: evidence-passing (Xie & Leijen 2020 “Effect Handlers in Haskell, Evidently” style — NOT Leijen 2017, which is type-directed selective CPS). Committed on pragmatic grounds: every effect on the Quartz roadmap is single-shot. Ndet was the sole multi-shot case; deferred indefinitely as nice-to-have. Stack-copying/fibers rejected (uniform but heavy per-op, per-target assembly burden); capability-passing rejected (highest type-theory load, duplicates evidence’s value proposition). Leijen 2017 read (2026-04-18, same session): validates type system, row polymorphism, scoped labels with duplicates (Model B), OPEN/CLOSE inference rules, and selective translation as the >80% cost reduction that makes the approach practical. But 2017 describes selective CPS, not evidence-passing — commitment validation continues in Xie-Leijen 2020 (next reading target). One design hole surfaced regardless of model: polymorphic-effect functions (
map,filter,fold) need special compilation (worker-wrapper + runtime dispatch) — ~20% core-library code bloat per Koka’s experience. - 2026-04-18 —
Iogranularity committed: hybrid. Atomic labelsFileIo,NetIo,ProcIo,StdIo,Clock,Random(exact set TBD with Decision #3).Iois a row alias for their union. Stdlib signatures default to atomic labels (information-dense for readers and for sandbox handlers);Ioalias available as escape hatch for code that doesn’t care. Implies positioning commitment: sandbox-friendly runtime is on Quartz’s future roadmap (capability-style effect handlers become the enforcement mechanism —with net_denied do -> run_untrusted() end). - 2026-04-18 — Effect row subtyping rejected: flat row equality + tail-polymorphic row variables (Koka-style). Subtyping’s HM-inference interaction and error-message complexity are well-known pain points; Koka tried it and walked back. Hybrid
Iomodel works cleanly with flat rows viacan Io | εtail polymorphism for call-site openness. - 2026-04-18 — Design Philosophy committed: “Invisible by Default, Rich When Wielded.” Three engagement levels (invisible / implicit / explicit), all first-class. Non-negotiable: trivial programs have zero effect ceremony;
cannever required for correctness; rich features reachable from any scope, not gated. Governs all downstream decisions. - 2026-04-18 —
Panicdefault handler committed: print structured message + trace to stderr, flush buffered output, exit 101. Custom handlers unlock crash reports, debugger hooks, test-harness capture, cleanup-before-die. Panic remains uncatchable as an effect semantically (no catching in userwith catch) but the default-handler pattern gives the rich-wielded escape hatch. - 2026-04-18 —
StdIobuffering discipline committed: Option D (line-buffered for TTY, block-buffered for redirect/pipe, auto-flush on panic via composed Panic handler). Resolves the libc “pipe-to-grep shows nothing” surprise and the “last writes lost on crash” footgun in one pattern. - 2026-04-18 — Phase 2 pilot committed: world-class CLI / config system (Ruby-Gemfile-quality, not Python-ecosystem style). Replaces ad-hoc argv handling; dogfoods
Reader<Config>+State<ArgState>. Quake tooling gets a DX win as a byproduct. - 2026-04-18 — Scoped labels committed: Model B — labels can appear multiple times in a row; handlers shadow lexically;
throwbinds to innermost matching handler; re-throw propagates to next outer. Matches Koka, Effekt, OCaml 5, Eff. Typechecker takes on label-scope discipline as the cost; expressiveness gain (nested catch, handler pipelining, re-throw) is worth it. Model A (flat, no duplicates) rejected: simpler typechecker but forces users to rename errors to layer catches, which is friction for no gain. - 2026-04-18 — Initial effect set committed (v6.0 blessed stdlib effects): 14 atomic effects + 1 row alias.
- Phase 1 (Throws pilot infrastructure):
Throws<E>,Panic,Log,FileIo,NetIo,ProcIo,StdIo,Clock,Random,Env - Phase 2:
State<S>,Reader<R>,Alloc - Phase 3 (migration):
Async - Row alias:
Io = {FileIo, NetIo, ProcIo, StdIo, Clock, Random, Env} - Deferred:
Ndet(multi-shot, indefinitely nice-to-have),Yield<T>(part of deferred Phase 4 — generators stay compile-time state-machine lowering until Phase 4 ever ships) - Explicitly subsumed / not blessed:
Debug/Trace(subsumed underLogwith severity levels),Exit(usePanicorThrows<ExitRequested>) Envrationale: atomic effect (not subsumed underProcIo). Security-audit use case — sandboxed runtimes and security software want to intercept every env var read/write. Default handler wrapsgetenv/setenv; custom handlers enable deny-all, log-all-reads, or deterministic fake-values-for-tests. Include inIorow alias for ergonomics; atomic form available when precise capability matters.
- Phase 1 (Throws pilot infrastructure):
- 2026-04-18 — Phase 1 pilot sequencing committed:
std/logmodule migrated first (mechanical proof — simplest possible effect, no error type, single-line handler), thenstd/parsemigrated (proves parameterizedThrows<E>end-to-end). Debugging loop: Log breaking = infrastructure bug; Parse breaking after Log works = Throws-specific bug. Smaller test before bigger test. - 2026-04-18 — Error message quality bar committed: six operational commitments (source location + carets, plain-English first, concrete compilable fix, grade by engagement level, never leak implementation vocab, runtime uncaught-throw shows trace + hint) + engagement-level detection heuristic (Invisible / Explicit / Expert, first-match-wins on per-error-site basis). Governing principle (user quote): “If we detect that they’re big enough to be on the ride, we can allow them in on some of the sausage making. Otherwise, we insulate.” Operational: invisible-level users never see “row,” “label,” “unification,” “ρ,” “substitution” in any effect error message. Detection: expert = uses row variables or effect-polymorphic combinators; explicit = function has
canannotation OR enclosingwith; invisible = neither. - 2026-04-18 — Xie & Leijen 2020 read (“Effect Handlers in Haskell, Evidently”). Validates evidence-passing compilation commitment. Surfaced CRITICAL design insight: three-kind operation taxonomy. Every effect op is classified at declaration as
value(constant resume),function(tail-resumptive, evaluates in-place, direct indirect call), oroperation(general, captures continuation via multi-prompt control). In Quartz’s committed effect set, ONLYThrows.throw(aborts — free),Async.{await, spawn, suspend}(Phase 3),Panic.panic(aborts), and deferredNdet.chooseare operation-kind. All other 10+ ops are function-kind and hit the fast path. Benchmark evidence in paper §6: evidence-passing library (EV) is constant-time in deep effect stacks, ~1.5x faster than next alternative in realistic workloads, within 1% of pure on abort-like effects. Key implementation insight: operation-kind ops reuse Quartz’s existing$pollstate-machine lowering — Phase 3 (async migration) is reframing, not rewriting. Full compilation strategy documented inEFFECTS_LLVM_COMPILATION_MEMO.md. Phase 0 exit criteria all met. - 2026-04-18 — Error-handling surface unified into four tiers (Assertion / Effect / Data / Panic) with exactly one canonical form per user intent. Resolves Option/Result/
!/$try/Panic ambiguity. Sub-decisions:!stays as assertion (panics, uncatchable, not effect-typed). Closes Open Decision #4.$try(x)macro deleted — replaced bytryprefix keyword.tryis optional sugar, not mandatory (per Design Philosophy — invisible default, loud when wielded).- Option propagation uses unified
Throws<Empty>(not a separatePartialeffect). One substrate. throwis an expression with Never type.reify { ... }block added for effect→Result conversion.??and.unwrap_or()both kept (ergonomic chaining, minor redundancy acceptable for “sexiest API” surface).- Error messages grade down by engagement level.
Next session when returning
- Confirm this plan’s shape or push back on phases / estimates
- Resume reading Leijen 2014 at §2 (motivating examples + concrete syntax)
- At end of §2, first explicit decision point: commit or defer the Phase 1 effect set