← Spec review
**Grade: D.** Part III's runtime machine is A-grade work, but Part II defines about thirty call semantics chosen by eight different criteria (types, annotations, overload order, parameter names, identifier spelling, and run-time TypeIds); it contradicts itself on evaluation order and on cleanup, and where it is silent the compiler had to guess.
Reviewer: opus. Sources: `spec/design/src/` (33 chapters) and `DECISIONS.md` (D1–D313). Probes: `~/vibe/review-spec/opus/probes/`, compiled and run with `~/vibe/fix/alpha/audit/probe.sh` on the audit build. Every quote below is copied from the spec or the decision log.
## Summary
The spec's central idea is defensible: a call to an effectful function runs it, so effectful code reads as direct style. Kotlin's `suspend` calls and Koka's effect inference both dispatch on the callee in the same way. The spec then adds exceptions to that idea. Whether an expression runs now, runs later, runs once, or runs on every retry can depend on the callee's parameter type, the overload candidate TypeScript tries, the declared type of the `const` that receives the value, whether the contract was written on the function or on its binding, the *name* of the callee's first parameter, the *spelling* of an identifier, or (in the planned projector) a TypeId checked at run time. Four confirmed probes show the consequences:
- Arguments are evaluated out of order and more than once (`trace=b,a,run`; `i=3`).
- The plain-JavaScript optimization drops a failure that the frame lowering keeps.
- A user property named `runSync` silently stops `main` from running.
- A contract that promises no failures compiles over a non-exhaustive match that turns a typed failure into a defect.
The fix is structural. Call semantics should dispatch on the callee's declared signature and on call-site syntax, nothing else. One normative elaboration chapter should state binding time, evaluation order and lexical context for every form, and every optimization must be proven equivalent to that elaboration.
---
## 1. Call-semantics inventory and proposed minimal design
### 1.1 Inventory: every distinct way a call behaves today
"Should be" uses the minimal design in 1.3: **R** (run), **P** (plain), **D** (delay by syntax), **H** (hatch), **K** (continuation operations), or "sugar" (a desugaring into those).
| # | Semantics | Example | Dispatched today by | Should be | Programs that change |
| --- | --- | --- | --- | --- | --- |
| 1 | Plain JS call | `double(4)`, `JSON.parse(t)`, `formatName(u)`, `new C()` | Callee's resolved type (non-Effect return), plus the trust class (built-in list, package, JSDoc tag, `trustedPackages`) and the **enclosing function's plainness**: TypeScript calls inside a plain function need no boundary (D263, kept by D282), and D101's "Trusted failures make named functions and free lambdas Effects but stay defects in plain callbacks" | **P**. Trust becomes a check, never a semantics: the same call has the same E everywhere | `JSON.parse` in a plain callback gains `SyntaxError` in E, or becomes EK4401 |
| 2 | Run call | `Users.find(id)`, `findUser(id)` | The call's **instantiated** type is `Effect` itself, or a union of only Effects (D24, D270) | **R**, keyed on the callee's **declared** return type | Generic callees whose `T` is instantiated to an Effect stop running at the call |
| 3 | Call of an Effect value | `p()`, `Port()`, `Clock.currentTimeMillis()` | The callee's type is an Effect, Config or Exit (calling a non-function) | **R** (call-site syntax `v()`) | None |
| 4 | Call of `any` | `x()` with `x: any` | Type `any`: "Its call returns its value" (D24); EK4409 is only a warning | **P**, and EK4409 becomes an error where the value is returned or awaited as a program | Silent unrun programs become errors |
| 5 | Optional call | `f?.(x)` | A special case: it runs (D68), although `f?.(x)` has the same type, `Effect<A> \| undefined`, as a call D270 keeps as data | **R** through rule 2 on the callee's signature | None |
| 6 | Service member, lookup | `Users.find(id)`, `Users()`, `Terminal.readLine()`, `FileSystem.readFileString(p)` | The receiver's type is a Context key (structural, D90), or a module whose **same-named export** is a key (D311) | Sugar: `S.m(a)` ≡ `S().m(a)`, with the receiver resolved by symbol | None |
| 7 | Control operation | `Approval.approve(x)` | The receiver class extends `Control` | Sugar over 6, plus capture (keep) | None |
| 8 | Data-type method | `stream.take(3)`, `fiber.join()` | The receiver's module, plus the first parameter's **name** (`self`, `…OrOptions`, D295's table), plus "no own member" | Sugar, from an explicit versioned table, with a diagnostic when an own member shadows a table entry | None today; an Effect upgrade can no longer reroute calls silently |
| 9 | By-name argument | `retry(fetchUser(id), p)`, `fork(f())`, `Effect.all([a(), b()])` | The **declared parameter type of the overload candidate** being tried (D44, D56, D147), a bare type parameter "fixed by another argument", record fields (D265), arrays (D49), and an overload ranking that prefers by-name (D257) | **D** only (`() =>`, block param, keyword) | Every by-name site becomes a type error with a mechanical fix; see 1.4 |
| 10 | Pipe topic | `fetchUser(id) \|> retry(%, p)` | The parameter type at `%` (D155); EK1008 when both forms apply; D272 rewrites a curried stage to `.pipe` | `%` is always the left side's value, evaluated once | By-name pipes become type errors with a fix |
| 11 | `pipe(...)` built-in | `pipe(() => f(), …)` | A look-ahead at the first stage's signatures (D110) | Effect's `pipe`, unchanged | Rare |
| 12 | Lambda form | `ids.map((id) => …)` | Each overload candidate's declared parameter; the first candidate that accepts wins (D56); for function-or-Effect parameters, the lambda's arity and whether it can pause (D39, D112) | One form per lambda, from its own body (plain when eligible, otherwise Effect); overload resolution picks a candidate and never re-forms the lambda | Swapped-overload libraries; see `register` in Ch. 13 |
| 13 | Function-value conversion | `stores.map(makeSync)` | The destination's declared type (D9, D66), plus a **run-time** marker, `$adapt(f) = f[$direct] ?? f` (D268) | Static only | None in well-typed code |
| 14 | Collection conversion | `Effect.all(stores.map(makeSync))` | The iterated declared type (D49, D111) | Keep, as a change of representation for thunks only | None |
| 15 | Held initializer | `const p: number!! = make()` | The **binding's declared type** (D262, D265, D269), extended to conditional, pipe and asserted initializers | Delete: write `const p = () => make()` | Type errors with a fix |
| 16 | Factory | `(): Effect<A> => { …; return () => s }` | The declared return type, plus the syntax of the return expression, plus "TypeScript would reject it as A" | An explicit marker (or TypeScript only) | Factories must say so |
| 17 | Return boundary | `return Exit.succeed(n)` | Whether the value is assignable to A, and whether the contract is **written or contextual** (D24 S22-3/S5-1, D241) | None: `return e` returns `e`'s value, and a mismatch is a type error | Probe p12's callback stops running its Exit |
| 18 | Hatch | `sync(f)`, `try(f, m, x)`, `callback(r)` | The callee's **spelling** when unshadowed (D148); sync or async by the `async` keyword or a `then` type; the signal by declared parameter count; mapper or input by the chosen overload (D231, D232) | **H**, keyed on the resolved prelude symbol; aliasing a hatch is an error | None |
| 19 | Block param | `withSpan("x") { … }`, `it.effect("t") { … }` | Whether the callee is an "Effect dual" (signature-shape matching, D188) | **D**: the block is always the last argument; duals take it through an explicit table | Rare |
| 20 | Keyword forms | `fork x`, `join x`, `await.all […]`, `provide L {…}`, `for concurrent`, masks | Call-site syntax, but `fork x` calls a local `fork` while `join x` ignores a local `join` (known gaps) | **D**, hygienic (the keyword never consults scope) | Code with a local `fork` |
| 21 | `do`, `catch`, `throw` expressions | `do {…}`, `x catch y`, `a ?? throw e` | `do`: runs, or is passed by name by the parameter type; `catch`: the left side is by-name; `throw`: an IIFE | `do` always runs; `catch` keeps its syntactic delay | `race(do {…}, …)` needs `() =>` |
| 22 | Tagged template | `` sql`…` `` | The tag's return type (D267) | **R** by rule 2 | None |
| 23 | Clause operations | `resume(v)`, `resumeWith(f)`, `abandon()` | Syntactic position inside a clause | **K** (keep) | None |
| 24 | Runner | `Effect.runPromise(p)` | The resolved symbol for EK1005 (D38), but the **spelling** for entry suppression (D135) | Resolved symbol everywhere | Probe p6 starts running `main` |
| 25 | TypeScript calling VibeLang | `title("42")` from `.ts` | The VibeLang declaration: unannotated returns a cold Effect; a plain annotation runs at once; a factory's body runs at once; a producer returns a Stream | Keep, stated in one normative table | None |
| 26 | `for…of` | `for (x of p)` | The iterated **static** type: producer, Stream, array or string, or another iterable whose body can pause | Keep, but define unions and `any` | Unions such as `Iterable \| Stream` |
| 27 | `using` initializer | `using x = acquire()` | Whether the initializer is a running Effect call (D94) | Keep; dispose once | Double-disposed resources |
| 28 | `throw` | `throw cause` | Static type: `Cause` becomes `failCause` (D177) | Keep, and define `unknown` and unions | Rare |
| 29 | `function*` | producer or JS generator | The **return annotation** (D271) | Syntax (a distinct keyword) | Annotated generators |
| 30 | Projector (B) | `$isRun(x)`, `$method(x, "m")` | A **run-time TypeId**, for the planned "emit switch" (D248–D254, EK9100) | Not normative, or deleted | Whatever the switch changes |
**Dispatch criteria in use today:** the callee's declared type; the call's instantiated type; the overload candidate's declared parameter type; a binding's declared type; written versus contextual return type; parameter names; identifier spelling; the enclosing function's plainness; and a run-time TypeId. Only rows 3, 20, 21 and 23 dispatch on call-site syntax.
### 1.2 Verdict against the principle
> "It's best practice to have as few call semantics as possible and only dispatch call semantics based on call site syntax."
The spec fails this principle at its core. The one dispatch it cannot avoid, "a call to an effectful callee runs", keys on the callee's declared function kind, as Kotlin's `suspend` does. That dispatch is defensible: it is nominal, it is fixed at the declaration, and it never changes after inference. Everything else in the table adds new dispatch keys. The worst of them change **evaluation strategy** (rows 9, 10, 15, 16 and 17), where an annotation or an overload decides whether an argument is evaluated zero, one or *n* times, and in what order. Algol 60's call-by-name (Jensen's device) is the canonical warning against this. Scala's `=> T` and Swift's `@autoclosure` show what prior art requires instead: an explicit marker on the callee, chosen by its author. Apple's guidance is "the context and function name should make it clear that evaluation is being deferred." VibeLang instead makes every Effect-typed parameter of every TypeScript library by-name, implicitly, per overload candidate. D257 then has to make overload *ranking* depend on evaluation strategy, a circularity no mainstream language accepts.
### 1.3 Proposed minimal design
| Semantics | Dispatch rule | Meaning |
| --- | --- | --- |
| **R: run** | `f(args)` where `f`'s **declared signature** returns the `Effect` interface (alias-expanded identity, not a subtype, not a union, not a type parameter); or `v()` where `v`'s type is an Effect type; or a VibeLang function whose inferred contract is effectful | Evaluate the callee, then the arguments, left to right, each once; then run the resulting cold Effect on the current fiber |
| **P: plain** | Every other call, and `new` | JavaScript's call. Trust (EK4401) is a static check on the callee; it never changes E or evaluation |
| **D: delay** | **Syntax only**: `() => e`, a block param `f(args) { … }`, and the operand of `fork`, `await.*`, `provide … { }`, masks, `for concurrent`, and the left side of `x catch …` | Produces a program; nothing inside is evaluated until it runs, and every run evaluates it afresh |
| **H: hatch** | The callee resolves to the prelude's `sync`, `try` or `callback` **symbol** | The first argument is TypeScript |
| **K: continuation** | `resume`, `resumeWith` or `abandon` in a clause | Chapter 20's rules |
Service calls, control operations and data-type methods are **desugarings** defined by resolved symbol (`S.m(a)` ≡ `S().m(a)`; `x.m(a)` ≡ `M.m(x, a)` from an explicit table). They are not semantics of their own. D7's conversion of a thunk to an `Effect` object where a TypeScript API expects one stays as a change of **representation**, not of evaluation: the thunk's body runs when the program runs in either form.
### 1.4 What existing programs change (all LANGUAGE CHANGE)
- **Every D147, D155, D262, D265 and D269 site** (`retry(f(x), p)`, `fork(f())`, `Effect.all([a(), b()])`, `const p: A!! = f()`, `x |> retry(%, p)`, `Effect.orDie(db.query(…))` in Ch. 4) becomes a **type error**: the call now runs and passes an `A` where an `Effect` is expected. The quick fix inserts `() =>`, or rewrites to the keyword or block-param form (`fork f()`, `await.all [a(), b()]`, `retry(p) { f(x) }`). No program changes silently at these sites. The census cost is 2.6% of tokens (D147's own measurement).
- **Silent changes** occur only where the parameter accepts both a value and an Effect (`unknown`, `any`, `A | Effect`, an unfixed `T`: `HttpRouter.add`, `Effect.withErrorReporting`). Ship a one-release migration diagnostic at every call in such a position.
- **Argument evaluation becomes left to right and once.** `retry(failUntil(i++), …)` evaluates `i++` once, as Effect TypeScript does. Today it evaluates it on every attempt (probe p1).
- Factories, held initializers and return-boundary runs disappear; S5-1 callbacks must write `return x()`.
**Fallback (option B) if the owner keeps implicit by-name.** In an Effect-typed parameter, a call is *evaluated but not run*: the callee and arguments are evaluated now, once, left to right, and the cold Effect is passed. That is literally what Effect TypeScript does, so D147's claim that these calls "mean what they mean in Effect" becomes true. Re-running the function body per retry is preserved, because each run of a cold Effect reruns the body. Only re-evaluation of argument expressions is lost. Option B still dispatches on the parameter type, but on run-or-not only, never on evaluation order or count.
---
## 2. Findings, ranked
### Blockers: the spec mandates or permits unsound or surprising behavior
#### B1. Implicit by-name passing, keyed on parameter type, breaks evaluation order and evaluation count
**Where:** Ch. 5 §The one rule; Ch. 13 §How program values are spelled and §Evaluation order; D147, D155, D257.
**Rule as written:**
> "A call runs where it stands, except where the expected type is `Effect`: there an expression that calls a `.vibe` or `Effect`-returning function, or looks up a service, is passed by name (D147), as a program that evaluates the expression on each run, as if `() =>` preceded it." (Ch. 13)
>
> "Generated code keeps JavaScript's evaluation order. Arguments are evaluated left to right, each once." (Ch. 13, the same chapter)
>
> "`fork(fetchReport())`, `Effect.timeout(slowReport(), "20 millis")`, … all mean what they mean in Effect." (D147)
**What is wrong:**
- The two Ch. 13 rules contradict each other, and the spec never says which one wins.
- An argument's subexpressions (`id`, `i++`, a TypeScript callee's eager prelude) are evaluated **at run time, once per run**, after later arguments.
- D147's equivalence claim is false: Effect TypeScript evaluates `fetchReport()`'s arguments once, at the call.
- Because the strategy is chosen per overload candidate (D56, D147), overload resolution must know evaluation strategy. D257 then ranks candidates by it: "a candidate whose parameter expects an `Effect` and passes a program argument by name outranks one that would run that same argument." Resolution and elaboration become mutually recursive.
**Evidence:**
- Probe `p1b-order`: `Effect.retry(eff(mark("a")), Schedule.recurs(mark("b")))` printed `trace=b,a,run`. Effect TypeScript gives `a,b,run`.
- Probe `p1-byname-order`: `retry(failUntil(i++), Schedule.recurs(5))` printed `retry=2 i=3`. Effect TypeScript fails with `i=1`.
**Prior art:**
- Algol 60 call-by-name and Jensen's device.
- Scala's by-name `=> T`, explicit at the declaration.
- Swift's `@autoclosure`, explicit at the declaration, with Apple's guidance on overuse.
- C#'s expression trees: `Expression<Func<T>>` is opt-in on the callee and changes no evaluation order of other arguments.
**Rewrite.** Replace §How program values are spelled with:
> "A call evaluates its callee and then its arguments, left to right, each exactly once, and then runs its result when rule R applies. A program is written only with delay syntax: `() => e`, a block parameter, or the operand of `fork`, `await.*`, `provide`, `uninterruptible`/`restore`/`interruptible`, `for concurrent`, or the left side of `catch`. A parameter's type never changes when or how often an argument expression is evaluated."
Delete D155's by-name topic and D257's ranking rule.
**LANGUAGE CHANGE.** See 1.4. Option B is the smaller change.
#### B2. Annotations change evaluation: held initializers, factories, return boundaries, generator kind, plain catch
**Where:** Ch. 13 §How program values are spelled (held `const`, factories); Ch. 5 §Storing, returning, and composing programs; D24 (S22-3 and S5-1 amendment), D241, D262, D265, D269, D271; Ch. 14 (EK2003 and D74/D92 methods).
**Rule as written:**
> "A `const` whose declared type is Effect holds its initializer in every scope … Mutable bindings and inferred or asserted types do not opt in." (Ch. 13)
>
> "Such a function is a **factory**: its body runs when called, as plain JavaScript" (Ch. 13)
>
> "at an Effect return boundary, a returned value assignable to the contract's declared success type A returns as data; only a returned Effect subtype that is not assignable to A runs. A contextual A of `unknown` or `any` names no success value: in a function with no written return contract … a returned subtype runs (S5-1)." (D24)
>
> "A generator function … whose explicit return annotation is a JavaScript iterator type … is plain JavaScript" (D271)
**What is wrong:**
- Adding, removing or *moving* a type annotation changes what runs and when. The program does not become ill-typed; it behaves differently.
- This violates the property every refactoring tool assumes: elaboration is coherent, and erasing or relocating an annotation that still type-checks preserves meaning.
- TypeScript's own design non-goal 5 is: "Add or rely on run-time type information in programs, or emit different code based on the results of the type system." VibeLang must break the letter of that for rule R, but nothing forces it to break it for evaluation order or count.
**Evidence:**
- Probe `p12-return-boundary`. The same body, `return Exit.succeed(n)`, returns an **Exit as data** under a written `unknown!!` (with warning EK4802), and **runs and returns 1** under the binding's declared type `(n: number) => Effect.Effect<unknown>`.
- The same probe shows the run-or-data predicate is not even type-directed consistently. `maybe(true)`, of type `Effect<number> | undefined`, stays data (D270), while `holder.f?.()`, of the same type, runs (D68).
**Prior art:**
- Haskell type classes are type-directed but coherent: global instance uniqueness makes elaboration independent of where annotations sit.
- Scala 2 implicit conversions are the cautionary tale; Scala 3 gates them behind `import scala.language.implicitConversions` because silent annotation-dependent conversions broke refactoring.
- Reynolds' coherence condition.
**Rewrite.** Add to Ch. 14:
> "**Coherence.** A type annotation may reject a program; it never changes when, whether, or how often an expression is evaluated or run. Removing an annotation from a program that still type-checks, or moving it between a function and its binding, does not change the program's behavior."
Delete held initializers (D262, D265, D269 const), the D24 return-boundary rules, and annotation-selected generator kind. Mark factories explicitly, or keep them TypeScript-only.
**LANGUAGE CHANGE.** Held consts and returned subtypes become type errors with fixes. JS generators need an explicit marker.
#### B3. The plain-JavaScript optimization drops failures: observable, and contradicted by Ch. 17
**Where:** Ch. 15 §Regions inside a plain function and §The rule; Ch. 17 §Rules; settled decision 4; G10.
**Rule as written:**
> "A plain JavaScript function may contain `try`, `catch`, and `finally`; they compile to JavaScript's own statements. Nothing inside can pause or be interrupted, so JavaScript's semantics match the machine's." (Ch. 15)
>
> "the compiler emits a plain JavaScript function whenever the difference is unobservable." (Ch. 15)
>
> "**A failure during cleanup is never dropped.** It is added to the cause being reported, on every path" (Ch. 17)
**What is wrong:** JavaScript's `finally` that throws **replaces** the exception in flight. The machine's `Final` rule combines them ("keep failing with `c` plus `fin`'s failures"). So the plain path changes the Cause, and the claim "JavaScript's semantics match the machine's" is false. Whether a function takes the plain path is inferred from its body (D10), so adding a `sleep` anywhere in the function changes how many failures its caller observes.
**Evidence:** Probe `p3-plain-finally`. The same `try { o.a.b } finally { o.c.d }` gives:
- `plain: 1 reason(s): Die:…(reading 'd')`
- `framed: 2 reason(s): Die:…(reading 'b') | Die:…(reading 'd')`
**Prior art:**
- Java 7 added `Throwable.addSuppressed` and try-with-resources because `finally` replacing the primary exception lost errors.
- Python 3 added `__context__` chaining.
- Effect's own `Cause.sequential`.
**Rewrite.** Add to Ch. 15:
> "A function whose body contains `finally` or `using` is eligible for the plain path only if its plain lowering preserves the Cause the frame lowering produces. The compiler lowers `finally` in plain code as `try { B } catch (e) { try { F } catch (f) { throw $combine(e, f) } throw e } F`, where `$combine` produces the same reasons, in order, that `Final` would."
Alternatively, exclude `finally` from the plain path.
Also add a normative equivalence rule: "Every optimization (the plain path, D274's single-result thunk, tail resume) must produce the same Exit, with the same reasons in the same order, as the frame lowering."
**LANGUAGE CHANGE: no.** This enforces the spec's own stated semantics.
#### B4. Exhaustiveness is unsound for open error families, so `!!` contracts can be satisfied by converting failures to defects
**Where:** Ch. 3 §Matching errors and outcomes; D209, D218, D304.
**Rule as written:**
> "A match without `default` over a finite union must cover every case" (Ch. 3)
>
> "Over any other type, an unmatched value throws the proposal's `TypeError` at runtime (`$noMatch`)." (D209)
>
> "A base whose subclasses carry their own tags therefore declares its tag type with a field named `_tag`" (D304)
**What is wrong:** A family base (`_tag: string`) is not a "finite union", so `match (e)` in a `catch` over that base is never checked. An unmatched subclass becomes a `TypeError` **defect**. The checker computes E from the arms, so the function's contract claims that nothing escapes. The typed error channel is sound only by convention.
**Evidence:** Probe `p9-open-family`. `handled(n): number!!` with `catch (e) { return match (e) { when SubA: 1; } }` compiles with no diagnostic. `handled(1)` exits with `Die:TypeError: No match arm matched SubB`.
**Prior art:**
- Rust's `#[non_exhaustive]` forces a wildcard arm for types that are open to the matcher.
- Kotlin requires `else` in a `when` over a non-sealed hierarchy and checks sealed ones.
- Scala 3 checks sealed hierarchies.
**Rewrite.** Replace D209's second sentence with:
> "A `match` without `default` whose subject's type is not a finite union is EK4701. A `match` on a caught failure must be exhaustive over the failure's declared classes; an error family whose base has a `string` tag is open, and needs `default`."
**LANGUAGE CHANGE.** Programs that compile today become errors.
#### B5. Meaning keyed on identifier spelling, and no hygiene requirement for generated names
**Where:** Ch. 13 §Where programs start; D135, D148, D208, D311, D152/D295; the known gaps for `fork` and `join`. Ch. 26 generated code; D303's corrections.
**Rules as written:**
> "The compiler recognizes runners by their resolved symbols" (Ch. 13)
>
> "A module that calls a runner at its top level, any call of a function named `runMain`, `runPromise`, `runFork`, `runSync`, or `runCallback`, starts its program itself and gets no entry statement" (D135)
>
> "A hatch is recognized by name when no scope around the call declares it" (D148)
>
> "a local variable named `Option` that shadows the module still compiles to `Option.isSome`." (D208)
>
> "A local declaration named `fork` shadows the keyword's function, so `fork x` calls it." versus "`join` is the keyword in `join x` even where a local named `join` exists" (known gaps)
>
> "A function is a method when its first parameter is named `self`" (D152)
**What is wrong:** These rules give meaning to the *string*, not to the binding. D135 contradicts Ch. 13 outright. The spec never requires that compiler-introduced names (`runtime`, `Effect_1`, `super_1`, `arguments_1`, `$caught`, region closures) be fresh. D303 records a real collision ("Both were `Effect_1` … the file declared the name twice and did not load").
**Evidence:** Probe `p6-runner-spelling`. `const helper = { runSync: (n: number) => n * 2 }; const doubled = helper.runSync(21)` at the top level makes `export function main` **never run**: no output, exit 0. The compiler confirms the cause in `entry.go:130`:
```go
var runnerNames = map[string]bool{"runMain": true, "runPromise": true, ...}
```
`callsRunner` matches any identifier or property name. Probe `p5-hygiene` shows the current build *is* hygienic for local `Effect` and `runtime` bindings, so the spec lags the implementation here; it does not justify it.
**Prior art:**
- Kohlbecker et al. 1986, Scheme's `syntax-rules` (R7RS).
- Racket's sets of scopes.
- Rust's macro hygiene for local variables.
- TypeScript's emitter allocates `_this` and `_a` with `makeUniqueName`.
**Rewrite.** Add a normative §Names to Ch. 13:
> "Every rule that gives a construct meaning keys on the resolved symbol of a declaration in the prelude, `effect`, or `vibelang-runtime`, never on an identifier's or parameter's spelling. A keyword form never consults scope. Every name the compiler introduces is fresh: distinct from every name visible at each point where generated code is placed. Runtime intrinsics are reached through import bindings the compiler owns."
Replace D295's name heuristic with an explicit, versioned table and a diagnostic when an own member shadows a table entry.
**LANGUAGE CHANGE: minor.** A local `fork` no longer captures `fork x`.
#### B6. Two renderings, and the spec does not say which is normative
**Where:** D248–D254; Ch. 28 EK9100 and EK9101 ("before the emit switch"); Ch. 13 §One syntax, several meanings.
**Rule as written:**
> "Because the meaning depends on types, the compiler needs TypeScript's type checker, not a source transform" (Ch. 13)
>
> "The text-only projector decides at run time whether a call's result runs (L3, `$isRun`)" (D248)
>
> "So L3 runs an `Exit` at run time, as before." (D251)
>
> "`$method` dispatches on the receiver's TypeId" (D252)
**What is wrong:** The decision log plans an "emit switch" to a renderer whose call semantics is decided at run time by inspecting values. That renderer cannot tell an `Exit` from `Effect.succeed` (D251, D253), and D294 admits the two renderings "differ only for a value outside the declared type". That makes two languages. The spec text never mentions the projector, except two diagnostic rows that refer to an undefined "switch".
**Rewrite.** Add to Ch. 27:
> "The type-directed compiler defines the language. Any other renderer is an implementation that must produce the same behavior on every well-typed program; a program it cannot render identically is a compile error in that renderer, never a different behavior."
Alternatively, delete the switch.
**LANGUAGE CHANGE: no** if the switch is dropped; **yes** if it ships.
### Major: gaps that force the compiler to guess
#### M1. The run-or-data predicate is non-parametric and enumerative
**Where:** Ch. 13 §Calls run `Effect` itself; D24, D109, D270, D68.
**Rule as written:**
> "A call runs its result only when the call's instantiated type is `Effect<A, E, R>` itself. Several Effect types extend `Effect` with structure of their own: `Config` …, `Exit` …, service keys …, and `TaggedError` instances"
**What is wrong:**
- "Instantiated" makes behavior depend on how a generic is instantiated. Parametricity fails: a generic helper returning `T` behaves differently at `T = Effect<A>`.
- "Structure of its own" is undefined; the spec defines it only by a list of examples.
- Unions (D270) and optional calls (D68) disagree (probe p12).
**Rewrite:**
> "Rule R applies when the callee's declared return type, after expanding aliases, is the `Effect` interface. A type parameter, a union, an intersection, or any interface that extends `Effect` is not."
**LANGUAGE CHANGE: minor.**
#### M2. No binding-time table, and "run" has two meanings
**Where:** Ch. 5, 13, 15, 26; D232, D269.
**What is wrong:** Binding time differs by form, and no chapter collects it:
| Form | When it is evaluated |
| --- | --- |
| Effect function | Arguments at the call; body per run |
| `$direct` | At the call |
| Factory | Body at the call |
| By-name argument | Per run |
| Held const | Call at the `const`; program per run; for a conditional initializer, the condition at the `const` |
| Service lookup | "at the call that uses it" (Ch. 1) |
| `Users()` in a layer | At build |
| Hatch inputs | "before the hatch starts" |
| Single-expression hatch `sync(crypto.randomUUID())` | At run |
| Pipe topic | Once, or per run |
| `catch` left side | By name |
The decision log uses "run" for both "call" and "execute a program". For example, D269's "neither branch runs at the const" is satisfied while the factory *is called* there: probe `p2-held-const` printed `made=2`.
**Rewrite.** Add a normative table to Ch. 13 with two verbs: **evaluate** (an expression to a value, which may be a cold Effect) and **run** (execute a cold Effect on the current fiber). Give every form its evaluate-time and run-time.
**LANGUAGE CHANGE: no.**
#### M3. Lexical context through desugaring is unspecified
**Where:** Ch. 26 §Regions; D92 (2026-10-23 amendment), D186, D187, D214, D274; known gaps.
**Rule as written:**
> "Region bodies are closures over the function's variables, so assignments inside a region are visible after it." (Ch. 26)
The spec says nothing about `this`, `arguments`, `new.target`, `super`, or a named function expression's own name. The decision log fixed `super` and `arguments` only after "a generator binds `super` and `arguments` afresh, so `super` there was a SyntaxError". Still open:
- "A hatch's function may still read an enclosing function's `this` or `arguments`."
- "Producer bodies capture their own generator's `arguments`."
- `new.target` is never mentioned.
- `do {}` and `throw` expressions desugar to arrow IIFEs, which break `break` and `continue` to outer loops and change `var` hoisting relative to the TC39 proposals.
**Evidence:** Probe `p4-lexical` shows the current build is right for `arguments` in a region, `this` in a method region, and a named function expression calling itself. The spec, not the compiler, is behind.
**Prior art:**
- ECMA-262's arrow functions capture lexical `this`, `arguments`, `super` and `new.target`.
- TypeScript's ES5 async and generator downlevel emits `_this`, `_arguments`, `_newTarget` and `_super`.
**Rewrite.** Add to Ch. 26:
> "Every desugaring preserves the meaning of `this`, `arguments`, `new.target`, `super`, and a named function expression's binding of its own name. A form whose desugaring cannot preserve one is a compile error naming it."
**LANGUAGE CHANGE: no.**
#### M4. `using` disposes a resource twice by rule, and the spec calls itself "decided"
**Where:** Ch. 6 §using; D94, D25; Ch. 0 §Status.
**Rule as written:**
> "A Disposable result also runs `[Symbol.dispose]()` at block exit. If its Effect release already disposes it, this disposes it twice (D94)."
>
> "Every rule in this specification is decided." (Status)
>
> D25 amendment: "Open."; D94: "needs an owner decision".
**What is wrong:**
- Releasing twice is a correctness bug. Closing a file descriptor twice can close a reused descriptor.
- ECMAScript ERM, C# `using` and Python's `with` all release exactly once.
**Rewrite:**
> "A `using` binding is released exactly once: by the release its acquisition registered if it registered one, otherwise by `[Symbol.dispose]()` or `[Symbol.asyncDispose]()`."
**LANGUAGE CHANGE: yes.** It is small.
#### M5. Module exports are snapshots in places, contrary to ES live bindings
**Where:** D303 and its CommonJS correction; Ch. 15 `$direct` variants; D268.
**Rule as written:**
> "an import from it reads its exports once, when it has run, so a later assignment to an exported `let` is not seen" (D303)
>
> "Re-exports from module declarations assign their value snapshots to CommonJS `exports`"
**What is wrong:**
- ECMA-262 module exports are live, indirect bindings (§16.2.1).
- The `$direct` companion and the D268 marker raise the same question for `export let f`, `f = g`, and the spec does not answer it.
- Ch. 1 and Ch. 26 show `export function f` lowered to `export const f = …`, which would lose hoisting. Probe `p7-hoisting` shows the build hoists correctly, so the spec's listings misdescribe the compiler.
**Rewrite:**
> "Every export of a `.vibe` module, including a `module` declaration's, is a live binding with its source declaration's hoisting; generated companions (`$direct`, adapters) are read through the live binding at each use."
**LANGUAGE CHANGE: no.**
#### M6. Callback effect polymorphism breaks alias transparency
**Where:** Ch. 16 §A callback type without a contract.
**Rule as written:**
> "only a function type written in place gains a contract, so a named one keeps TypeScript's meaning and takes plain functions."
**What is wrong:** `f: (a: number) => number` and `f: Fn`, with `type Fn = (a: number) => number`, mean different things. Type abbreviations stop being abbreviations, so extracting a type alias, a routine refactor, changes which programs compile.
**Evidence:** Probe `p11-alias`. The inline type accepts an effectful lambda. The alias reports `EK4301: this callback can pause (eff), but viaAlias calls it synchronously`.
**Prior art:** Type synonyms are transparent in Haskell, OCaml and TypeScript itself. Effect polymorphism in Koka and Frank is explicit (`<e>`) or is attached to the arrow wherever it appears, never to the syntactic form of the type reference.
**Rewrite.** Make polymorphism uniform: every function-typed parameter without a contract is effect-polymorphic, including aliases. A plain-only callback says so with `=> A!!`.
**LANGUAGE CHANGE: yes.**
#### M7. The E channel of the same call depends on context
**Where:** D101, D263 (kept by D282), D73; Ch. 15 §The rule.
**Rule as written:**
> "Trusted failures make named functions and free lambdas Effects but stay defects in plain callbacks" (D101)
>
> "Calls of TypeScript code, including callback parameters and trusted or untrusted imports, are ordinary calls needing no trust boundary" (Ch. 15)
**What is wrong:**
- `JSON.parse`'s `SyntaxError` is in E in one lambda and a defect in another.
- Adding `: number` to a function silences EK4401 for every untrusted call in it.
- D73's "complete" contract rule means that writing or omitting R changes the E callers see. In probe p9, `number!Base` made callers see `SubA | SubB`, while `number!Base!` made them see `Base`.
**Rewrite:**
> "A call's expected failures depend only on the callee. A plain function that calls a callee with listed failures must handle them or is EK2003. Any written E is the callers' E."
**LANGUAGE CHANGE: yes.**
#### M8. Block-owned forks escape silently
**Where:** Ch. 7 §Concurrent loops; D96.
**Rule as written:**
> "Returning a block-owned child currently returns a fiber already interrupted when that block exited; the compiler does not warn."
`() => fork(p)` and `() => { return fork(p) }` also differ in lifetime and in R.
**Prior art:** Trio nurseries and Kotlin's `coroutineScope` make a child unable to outlive its scope, and Swift's `TaskGroup` makes it statically impossible.
**Rewrite:**
> "A block-owned Fiber that can escape its block (returned, stored outside it, or captured by a closure that outlives it) is a compile error; an expression-bodied arrow is a block for ownership."
**LANGUAGE CHANGE: yes.**
#### M9. A throw expression silently does nothing in parameter defaults
**Where:** D186; known gaps (TC39 forms).
**Rule as written:**
> "A throw expression in a parameter default of an Effect function does not run (TypeScript rejects `yield` in parameters)."
**What is wrong:** A known gap that is silent misbehavior: a `throw` that does not throw. An unimplementable position must be a compile error.
**Rewrite:** "A throw expression where the lowering cannot run it is a compile error."
**LANGUAGE CHANGE: no.** It turns silent misbehavior into an error.
#### M10. Silent "program returned as data" is only a warning
**Where:** EK4802, EK4409, EK1012 (Ch. 28); D241.
**What is wrong:** A returned program that is never run is the language's most common silent bug, and the decision log records several in real code (`Sink.forEach((n) => announce)`, `Clock.currentTimeMillis` returned unrun). These are warnings by default, and EK4802's own note suggests `Exit.succeed(1)()`. Under B2's coherence rule most of these become type errors. Until then, make them errors that a project can lower.
**LANGUAGE CHANGE: no.**
### Minor: wording and quality
- **m1. Contradictions about TC39 proposals.** Ch. 12 says "TC39's pattern matching proposal is at Stage 1 and is not used". Appendix B says pattern matching and throw expressions are "Not adopted" and Await Dictionary "Not needed". Part I adopts all three (D186, D196, D206).
- **m2. Three entry stories.** Ch. 1 has `if (import.meta.main) NodeRuntime.runMain(main())`; Ch. 11 has `runtime.$entry(…)` with a platform import (D312); Ch. 12, Ch. 13 and Appendix C have a top-level `NodeRuntime.runMain(() => main())`. The spec should say which one is normative.
- **m3. Ch. 29 is corrupt.** It lists D13 six more times (lines 158–163), omits D247–D259, D261–D280, D283–D304 and D313, and still states D125's name "Effeck". The Intro and Appendix A say DECISIONS.md holds "D1-D60", but it holds D1–D313. D310–D313 are "provisional, pending the owner's review", which contradicts "Every rule … is decided".
- **m4. Undefined term.** Ch. 14 §Recursion says a call "could hold the program instead, as an argument or a logical operand can". No rule defines a logical operand as a program position.
- **m5. Context-dependent defaults for `A!E`.** `A!E` infers R on a function with a body and means `never` elsewhere (D127, D309), so the same syntax has two meanings by context. Proposal: an omitted slot is `never` everywhere, and inference only through `infer`. LANGUAGE CHANGE.
- **m6. Implementation detail presented as spec.** Ch. 15 §Recursion ("Native entries share the eager budget with the projector's `$fast` protocol … Large native frames consume more of the budget") and Ch. 28's registry ("A registry entry doesn't prove its check … is implemented") describe the implementation, not the language. Move them to `VIBELANG.md`.
- **m7. `for…of` and `throw` are undefined on unions and `any`.** Ch. 8 says "The static type decides", and D177 dispatches on `Cause`, but neither defines `Iterable<T> | Stream<T>` or `unknown`.
- **m8. Data-type methods follow the effect version.** D152 says "a member that a later Effect release adds to a type takes the call from the module function of the same name". The exact pin of `effect` mitigates this; a shadowing diagnostic would remove it.
- **m9. Generated listings disagree with the compiler.** Ch. 13 shows `yield* Approval.approve(x)`, which D91 amended. Ch. 1 and Ch. 26 show `export const` for function declarations, which probe p7 shows is not the emitted shape. Appendix C still uses `Effect.all([() => a(), () => b()])` where Part I uses by-name calls.
- **m10. Opposite semantics under related names.** `await.race` takes the first to finish and `race` takes the first success (D196). This is controversial by the log's own label; at minimum the hover must say so.
---
## 3. What the spec gets right
- **The machine (Ch. 20)** is a real transition system. Frames, prompts, owners and atomic rules are mapped field by field onto Effect 4, and every claim carries an evidence tag ([Effect], [Kernel], [Rule]). The Ch. 18 claim is falsifiable, as a design claim should be.
- **One-shot continuations with ownership.** Double resume is a defect at the caller, continuations are fiber-affine, and abandonment runs finalizers. It is grounded in OCaml's `discontinue`, Swift's checked continuations and Kotlin, with tests T2–T4 and O01–O08.
- **Snapshot Context versus delimited dynamic binding (Ch. 22)** is argued from first principles: Effect's frames can only restore absolute Contexts, DI stays stable, and the request-scoped leak CVE is cited. The cost (`HandlerPromptExited`) is stated.
- **Cleanup in frame code** never drops a failure (D15, K12), and EK2006 forbids control leaving `finally`. That is exactly Java's lesson. B3 is the one place the plain path breaks it.
- **Nominal services with derived keys** and a duplicate-key check fix Effect's string-key aliasing, with a probe (`service-identity/probe.ts`).
- **Recursive inference** is a fixpoint inside the SCC, with a termination argument (the rows only grow and are finite) and a known counterexample (`isEven`/`isOdd`).
- **Regions compile to Effect frames, not JavaScript `try`**, justified by F1–F3: Effect never calls a generator's `return()`.
- **The current compiler is ahead of the spec** on hygiene (probe p5), lexical context (p4), compound-assignment order (p8: `x=2 order=idx,val`) and hoisting (p7).
---
## 4. Reading list: the three changes that would remove the most compiler bugs
1. **Delete type-directed evaluation strategy.** Replace D147, D155, D257, D262, D265, D269, and D24's return-boundary amendment with delay by syntax and call-by-value arguments, evaluated left to right, each once (B1, B2; option A, or option B as the fallback). This removes the out-of-order and twice-evaluated operands, the circularity between overload resolution and evaluation, three held-initializer variants, factory phases, EK1008, and the run-or-data predicate at return boundaries.
2. **Key every rule on resolved symbols and declared signatures, and require hygiene** (B5, M1, B6). This removes spelling-based runners, hatches, `Option` matchers and `fork`/`join` shadowing; name capture by generated helpers; non-parametric dispatch on instantiated types; and the planned run-time TypeId dispatch.
3. **Write one normative elaboration chapter.** It should give source-to-machine translation rules in the style of ECMA-262's abstract operations, stating binding time, evaluation order and lexical-context preservation for every form (M2, M3). It should also require every optimization (the plain path, D274 thunks, tail resume) to be Exit-equivalent to the frame lowering, reason for reason (B3). This removes the `this`, `arguments` and `super` losses, the `finally` that replaces failures, and every fast path that evaluates operands differently from its slow path.
---
## Appendix: probes
Run with `bash ~/vibe/fix/alpha/audit/probe.sh ~/vibe/review-spec/opus/probes/<name>`.
| Probe | What it shows | Output |
| --- | --- | --- |
| `p1-byname-order` | Argument side effects re-run on every retry | `retry=2 i=3` |
| `p1b-order` | A later argument is evaluated before an earlier by-name one | `trace=b,a,run` |
| `p2-held-const` | A held conditional initializer calls the factory once; "run" is ambiguous in D269 | `direct: 1,1 made=1; conditional: 2,2 made=2` |
| `p3-plain-finally` | The plain path drops the first defect; the frame path keeps both | `plain: 1 reason(s)` against `framed: 2 reason(s)` |
| `p4-lexical` | `arguments`, `this` and named self-reference are correct today | `args=5`, `this=5`, `fact=120` |
| `p5-hygiene` | Local `Effect` and `runtime` do not capture generated code today | `caught user`, `mine1` |
| `p6-runner-spelling` | A user property named `runSync` stops `main` from running | No output, `RUN_EXIT=0` |
| `p7-hoisting` | A function declaration used before its definition works | `early=7` |
| `p8-compound-order` | Compound assignment and element-assignment order are correct | `x=2 order=idx,val` |
| `p9-open-family` | `!!` holds while a typed failure becomes a defect | `subB -> Die:TypeError: No match arm matched SubB` |
| `p11-alias` | An alias and the same inline function type mean different things | `EK4301` on the alias only |
| `p12-return-boundary` | A written or contextual `unknown` changes whether an Exit runs; `?.` against a union changes whether the call runs | `an Exit (data)` against `value 1`; `an Effect (data)` against `2` |