Severity
SHOWSTOPPER — silent wrong answers, on main, demonstrated by the flagship example file.
The ?: Result-default operator is the compiler-sanctioned way to satisfy [ARITH-CHECKED], and the overwhelmingly common way to use it is to fabricate a value — 3,405 occurrences of ?: 0 across tests/ and examples/. On overflow, an arithmetic failure is silently replaced by a plausible wrong number. No error, no trap, exit code 0.
This is precisely what CLAUDE.md's Broken Code Process forbids:
Silently-wrong output is worse than a crash: a panic is found in seconds; a silent failure never is.
This is the sequel to #187
#187 correctly demanded that overflowing arithmetic stop masquerading as an infallible plain int. That was implemented — [ARITH-CHECKED] in docs/specs/0013-ErrorHandling.md now makes integer + - * return Result<int, MathError>, and both #187's and #163's original repros are now rejected or return Error on main:
$ osprey wrap.osp --run # let max = 9223372036854775807; print("${max + 1}")
Error(integer overflow)
$ osprey unwrap.osp --run # fn add(x, y) -> int = x + y (the #163 auto-unwrap)
unwrap.osp: type mismatch: cannot unify Result<int, MathError> with int
Result Preservation is genuinely enforced — you cannot ignore the Error branch:
$ osprey ignore.osp --run # fn sq(x) = x * x used where an int is required
ignore.osp: type mismatch: cannot unify Result<int, MathError> with int
But the fix relocated the silent failure rather than eliminating it. The type checker now demands a value at every arithmetic site, and 0 is the cheapest value to hand it. Two's-complement wrapping was replaced by fabricated zeroes.
Repro — all three from tests/regressions/basics/osprey_mega_showcase.test.osp on main
This is the "🦅 Osprey in one screen" file: the first program a newcomer reads, and the corpus's teaching example.
1. Masked result — line 44, fn sq(x) = x * x ?: 0
fn sq(x) = x * x ?: 0
print("sq(5)=${sq(5)} sq(4e9)=${sq(4000000000)}")
4000000000² = 1.6×10¹⁹. The answer printed is 0.
2. Reset accumulator — line 45, the fold in crunch()
The fallback is the accumulator itself, so an overflow mid-fold silently restarts the running sum at zero and the fold continues:
fn big(x) = x * 1000000000000000000 ?: 0
print("${range(1, 5) |> map(big) |> fold(0, fn(a, b) => a + b ?: 0)}")
fold of 1e18,2e18,3e18,4e18 = 0
3. Vanished deposit — line 30, the ledger effect handler
post amount => { balance = balance + amount ?: balance balance }
balance 9223372036854775800 + deposit 100 = 9223372036854775800
The deposit disappears, the handler reports success, and the Console.emit arm prints "deposit 100 → balance …" with the unchanged balance. The file's headline demonstration of algebraic effects silently loses money.
Secondary: unreachable ?: branches are accepted
Line 43: fn even(x) = (x % 2 ?: 1) == 0. The divisor is the literal 2; % fails only on a zero divisor, so this fallback is dead code — noise in the shape of safety. examples/failscompilation/result_default_on_plain_value.ospo exists specifically to reject a dead ?: on a plain value, but a provably-nonzero literal divisor is accepted.
Line 19: perform Ledger.post((0 - 90) ?: 0) — were it ever to fire, a 90-unit withdrawal silently becomes a 0-unit no-op.
Scale
Fallback shapes across tests/ and examples/ (6,204 ?: in 46,803 lines — one every 7.5 lines, in 181 of 308 source files):
| fallback |
count |
?: 0 |
3,405 |
?: 99 |
174 |
?: 9 |
136 |
?: false / ?: true |
110 |
?: <accumulator> |
~90 |
Only 18 files ever inspect the error (matching MathError, "division by zero" or "integer overflow"). The corpus teaches, by 3,405 examples, that the way to satisfy Result Preservation is to invent a number.
Why the corpus can't catch it
Every one of these sites is green. The goldens were recorded from runs where the fallback never fired, so the masked path has no coverage anywhere. A silent-wrongness bug that only manifests on overflow is invisible to a differential harness that never overflows.
Suggested direction (design decision, not prescribed)
The root cause is ergonomic: the type system demands a total value at every arithmetic site and offers ?: as the cheapest exit. Options, roughly in order of how much they reduce fabrication pressure:
- Make integer
+ - * and unary - yield plain int with a runtime trap on overflow — Zig/Swift/Rust-debug semantics. Keeps the existing checkedAdd/checkedSub/checkedMul builtins (already 148 uses in the corpus) as the opt-in Result form. Deletes the fabrication pressure at ~6,200 sites and makes failure loud and located. Contradicts the current "never wraps or panics" sentence in 0013-ErrorHandling.md:44, which would need to change deliberately.
- Reject a
?: fallback that is a bare literal on a MathError channel, forcing match or an explicit checked* call — keeps the current type contract, removes the cheap exit.
- Reject provably-unreachable
?: branches (literal nonzero divisor) — fixes the secondary issue independently and is worth doing under any of the above.
Tests first
Per CLAUDE.md ("write the test before the fix"), the three repros above should land as red regression tests pinning the masked square, the reset accumulator and the vanished deposit before any design change is made, so whichever direction is chosen has to prove it fixed them.
Found while assessing Default-flavor syntax ergonomics. Verified on origin/main: tests/regressions/basics/osprey_mega_showcase.test.osp is byte-identical there, and git diff origin/main..HEAD over crates/osprey-types/src/expr.rs, crates/osprey-codegen/src/expr.rs and crates/osprey-codegen/src/result.rs is empty — the branch this was found on adds tests only, so the behaviour above is main's.
Severity
SHOWSTOPPER — silent wrong answers, on
main, demonstrated by the flagship example file.The
?:Result-default operator is the compiler-sanctioned way to satisfy[ARITH-CHECKED], and the overwhelmingly common way to use it is to fabricate a value — 3,405 occurrences of?: 0acrosstests/andexamples/. On overflow, an arithmetic failure is silently replaced by a plausible wrong number. No error, no trap, exit code 0.This is precisely what
CLAUDE.md's Broken Code Process forbids:This is the sequel to #187
#187 correctly demanded that overflowing arithmetic stop masquerading as an infallible plain
int. That was implemented —[ARITH-CHECKED]indocs/specs/0013-ErrorHandling.mdnow makes integer+ - *returnResult<int, MathError>, and both #187's and #163's original repros are now rejected or returnErroronmain:Result Preservation is genuinely enforced — you cannot ignore the Error branch:
But the fix relocated the silent failure rather than eliminating it. The type checker now demands a value at every arithmetic site, and
0is the cheapest value to hand it. Two's-complement wrapping was replaced by fabricated zeroes.Repro — all three from
tests/regressions/basics/osprey_mega_showcase.test.osponmainThis is the "🦅 Osprey in one screen" file: the first program a newcomer reads, and the corpus's teaching example.
1. Masked result — line 44,
fn sq(x) = x * x ?: 04000000000²= 1.6×10¹⁹. The answer printed is 0.2. Reset accumulator — line 45, the
foldincrunch()The fallback is the accumulator itself, so an overflow mid-fold silently restarts the running sum at zero and the fold continues:
3. Vanished deposit — line 30, the ledger effect handler
The deposit disappears, the handler reports success, and the
Console.emitarm prints"deposit 100 → balance …"with the unchanged balance. The file's headline demonstration of algebraic effects silently loses money.Secondary: unreachable
?:branches are acceptedLine 43:
fn even(x) = (x % 2 ?: 1) == 0. The divisor is the literal2;%fails only on a zero divisor, so this fallback is dead code — noise in the shape of safety.examples/failscompilation/result_default_on_plain_value.ospoexists specifically to reject a dead?:on a plain value, but a provably-nonzero literal divisor is accepted.Line 19:
perform Ledger.post((0 - 90) ?: 0)— were it ever to fire, a 90-unit withdrawal silently becomes a 0-unit no-op.Scale
Fallback shapes across
tests/andexamples/(6,204?:in 46,803 lines — one every 7.5 lines, in 181 of 308 source files):?: 0?: 99?: 9?: false/?: true?: <accumulator>Only 18 files ever inspect the error (matching
MathError,"division by zero"or"integer overflow"). The corpus teaches, by 3,405 examples, that the way to satisfy Result Preservation is to invent a number.Why the corpus can't catch it
Every one of these sites is green. The goldens were recorded from runs where the fallback never fired, so the masked path has no coverage anywhere. A silent-wrongness bug that only manifests on overflow is invisible to a differential harness that never overflows.
Suggested direction (design decision, not prescribed)
The root cause is ergonomic: the type system demands a total value at every arithmetic site and offers
?:as the cheapest exit. Options, roughly in order of how much they reduce fabrication pressure:+ - *and unary-yield plainintwith a runtime trap on overflow — Zig/Swift/Rust-debug semantics. Keeps the existingcheckedAdd/checkedSub/checkedMulbuiltins (already 148 uses in the corpus) as the opt-in Result form. Deletes the fabrication pressure at ~6,200 sites and makes failure loud and located. Contradicts the current "never wraps or panics" sentence in0013-ErrorHandling.md:44, which would need to change deliberately.?:fallback that is a bare literal on aMathErrorchannel, forcingmatchor an explicitchecked*call — keeps the current type contract, removes the cheap exit.?:branches (literal nonzero divisor) — fixes the secondary issue independently and is worth doing under any of the above.Tests first
Per
CLAUDE.md("write the test before the fix"), the three repros above should land as red regression tests pinning the masked square, the reset accumulator and the vanished deposit before any design change is made, so whichever direction is chosen has to prove it fixed them.Found while assessing Default-flavor syntax ergonomics. Verified on
origin/main:tests/regressions/basics/osprey_mega_showcase.test.ospis byte-identical there, andgit diff origin/main..HEADovercrates/osprey-types/src/expr.rs,crates/osprey-codegen/src/expr.rsandcrates/osprey-codegen/src/result.rsis empty — the branch this was found on adds tests only, so the behaviour above ismain's.