Skip to content

effects: put the Result envelope in every operation's return type - #1607

Merged
sergey-shandar merged 6 commits into
mainfrom
claude/io-effect-migration-stage-3
Aug 16, 2026
Merged

effects: put the Result envelope in every operation's return type#1607
sergey-shandar merged 6 commits into
mainfrom
claude/io-effect-migration-stage-3

Conversation

@sergey-shandar

Copy link
Copy Markdown
Contributor

Stage 3 of the IoEffect migration
(fjs/effects/todo/io-effect-migration.md). Stages 1 and 2 — the types and the
composition API — shipped in #1599 and #1604.

Every operation in fjs/effects/node and fjs/effects/memory now declares a
Result return, every runner answers with one, and host failures are
normalized into a structured IoError instead of the bare unknown that used
to erase the NotImplemented distinction.

Where the envelope lives

In the operation's declared return type, not in a wrapper a constructor
applies — that is the only channel through which stage 6 can deliver
error(notImplemented) to a Do node's continuation. Two aliases carry it:

  • OpResult<T> = Result<T, NotImplemented> — an operation with no failures
    of its own (now, randomInt, all, write, read, sandbox, test,
    the memory ops, …).
  • IoResult<T> = Result<T, NotImplemented | IoError> — refined in place from
    Result<T, unknown> for host IO.

Runners stay total: NotImplemented is in the type model and nothing produces
it yet.

IoError

readonly['ioError', { code?: string, message: string }] — a tagged tuple
beside NotImplemented so the shared channel stays discriminable, in
fjs/effects/node/types.ts beside the operations that report it. The bare
unknown had to go for a specific reason: NotImplemented | unknown collapses
to unknown, so a program could not tell "this runner cannot do it" from "the
host tried and failed".

toIoError normalizes a thrown value at the single boundary where an impure
runner catches, keeping the OS code when the host attached one — which is
what isNotFound still reads. The virtual runner reports the same shape, so a
proof against the virtual filesystem is still evidence about the real one.

SandboxResult.result stays a Result inside the envelope: it is the
sandboxed function's outcome — returned data, not effect status.

Write and Read stayed OpResult. They are host IO and could fail
(EPIPE), but this stage's rule for a currently-infallible handler is to wrap
its output in ok(...), not to invent a failure the runner never reported.
Promoting them is a behavior change and belongs in its own issue.

The value-discarding sweep

This is the part tsc cannot do, and the hazard the whole migration is about:
a continuation that reads the operation's value broke loudly, while
() => next kept compiling and silently ignored the new error channel. Every
such site now states a policy, and two of them are named so the stragglers stay
findable:

  • unwrapStep (fjs/effects/io/module.f.mjs) — leave the layer by
    panicking on the error branch. One greppable name rather than an unwrap
    buried in a continuation, so the set of sites that have not yet chosen
    anything better is exactly the set this name marks. That is the worklist
    stage 4 starts from.
  • exitStep / errorMessage (fjs/effects/node/module.f.mjs) — a
    NodeProgram's exit-code policy: report the failure on stderr, exit 1.
    This replaces step(log(x), () => pure(0)) at program tails, which used to
    exit 0 whether or not the write landed.

errorExit keeps discarding its own write's outcome, deliberately and with the
reasoning in its JSDoc: the program is already failing, and the exit code is
1 whether or not stderr accepted the bytes.

Consumers

Per the plan, consumers are not migrated to Io composition here — that is
stage 4. Each one keeps its existing failure policy and changes only what the
envelope forces: modules that already panicked on IO failure (fjs/dev,
fjs/ci, fjs/nanvm/update, the test reporter) now say so with unwrapStep,
and the two places where the envelope simplified something took it —
readUtf8File is now mapStep over the Io layer, and the MCP stdio transport
returns write's own result instead of rebuilding an ok.

Checks

npx tsc clean; fjs t 2919 pass / 0 fail; npm run cov reports 100%
line/branch/function with the #1605 gate enabled; npm run ci-update
regenerates nothing.

Changelog:

  • BREAKING CHANGES: effects: every operation's return type carries a
    Result. Infallible operations answer OpResult<T>
    (Result<T, NotImplemented>); host IO answers IoResult<T>, whose error is
    now the structured NotImplemented | IoError rather than unknown. Runner
    handlers wrap their output in ok(...); isNotFound takes a channel error
  • effects/node: new IoError / IoErrorInfo / OpResult types with
    ioError, toIoError, errorMessage and exitStep, the NodeProgram
    exit-code policy
  • effects/io: new unwrapStep, which leaves the layer by panicking on the
    error branch

🤖 Generated with Claude Code

https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb


Generated by Claude Code

Stage 3 of the IoEffect migration. Every operation in `fjs/effects/node` and
`fjs/effects/memory` now declares a `Result` return, every runner answers with
one, and host failures are normalized into a structured `IoError` instead of
the bare `unknown` that used to erase the `NotImplemented` distinction.

The envelope lives in the operation's declared return type rather than in a
wrapper a constructor applies, because that is the only channel through which
stage 6 can deliver `error(notImplemented)` to a `Do` node's continuation.
Two aliases carry it: `OpResult<T>` (`Result<T, NotImplemented>`) for an
operation with no failures of its own, and `IoResult<T>` — refined in place —
for one that performs host IO. Runners stay total: `NotImplemented` exists in
the type model and nothing produces it yet.

`IoError` is `readonly['ioError', { code?, message }]`, a tagged tuple beside
`NotImplemented` so the shared channel stays discriminable, living in
`fjs/effects/node/types.ts` beside the operations that report it. `toIoError`
normalizes a thrown host value at the single boundary where an impure runner
catches, and the virtual runner reports the same shape, so a proof against the
virtual filesystem is still evidence about the real one. `SandboxResult.result`
stays a `Result` inside the envelope: it is the sandboxed function's outcome,
returned data rather than effect status.

`Write` and `Read` stayed `OpResult`. They are host IO and could fail (EPIPE),
but this stage's rule for a currently-infallible handler is to wrap its output
in `ok(...)`, not to invent a failure the runner never reported; promoting them
is a behavior change that belongs in its own issue.

The value-discarding sweep is the part `tsc` cannot do: a continuation that
reads the value broke loudly, while `() => next` kept compiling and silently
ignored the new channel. Each such site now states a policy, and the two named
ones are what stage 4 will start from — `unwrapStep` (leave the layer by
panicking; one greppable name rather than an `unwrap` buried in a continuation)
and `exitStep` / `errorMessage` (a `NodeProgram`'s exit-code policy: report on
stderr, exit 1). `errorExit` keeps discarding its own write's outcome
deliberately: the program is already failing, and the exit code is 1 whether or
not stderr accepted the bytes.

`npx tsc` is clean, `fjs t` passes (2919 tests), and `npm run cov` reports
100% line/branch/function with its gate enabled.

Changelog:
- **BREAKING CHANGES:** `effects`: every operation's return type carries a
  `Result`. Infallible operations answer `OpResult<T>`
  (`Result<T, NotImplemented>`); host IO answers `IoResult<T>`, whose error is
  now the structured `NotImplemented | IoError` rather than `unknown`. Runner
  handlers wrap their output in `ok(...)`; `isNotFound` takes a channel error
- `effects/node`: new `IoError` / `IoErrorInfo` / `OpResult` types with
  `ioError`, `toIoError`, `errorMessage` and `exitStep`, the `NodeProgram`
  exit-code policy
- `effects/io`: new `unwrapStep`, which leaves the layer by panicking on the
  error branch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
functionalscript c902eb7 Commit Preview URL

Branch Preview URL
Aug 16 2026, 06:02 AM

Comment thread fjs/effects/memory/proof.f.mjs Fixed
Comment thread fjs/effects/node/module.f.mjs Fixed

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One call site was missed by the sweep: fjs/cas/cli/module.f.mjs:53, the cas get handler —

([r, v]) => r === 'error' ? errorExit('e: ' + String(v)) : pure(0)

v used to be the thrown Error; it's now the IoError tuple, and String(['ioError', {...}]) gives ioError,[object Object]. Ran the real CLI on both trees with HOME pointed at a temp store:

  • main: e: Error: ENOENT: no such file or directory, open '.../.cas/b2/8v/...'
  • head: e: ioError,[object Object]

Exit code is 1 either way and the success path is byte-identical — only the diagnostic is destroyed. Notably String(...) accepted the new shape silently, which is the exact hazard this stage is meant to remove. The fix is already in the PR: errorMessage(v).

Everything else checks out. Behavioral probe of 45 cases — real FS read/write/stat/access/rename/rm/readdir/readBytes/createExclusive/writeFromStream/import/fetch, missing-file and chmod-000 permission-denied, memory ops, virtual FS, and fjs/cas add/read/list end-to-end including the ENOENT-swallowing empty-store path — every success value identical, every failure still a failure. code and message are preserved verbatim (ENOENT/EACCES/EEXIST/ERR_MODULE_NOT_FOUND); stack/cause/errno/syscall/path are dropped and no consumer reads them (exec's numeric code too, but exec has no callers).

"Runners stay total" holds — no error(['notImplemented', …]) exists at this head, and virtual's unimplemented ops still throw as on main.

Harness check, since emergent_testing changed: broke fjs/types/nullable/proof.f.mjs and got 2918 pass / 1 fail, exit 1, failing proof named — still reports correctly.

npx tsc exit 0; fjs t → 2919 pass / 0 fail; npm run cov exit 0 at 100/100/100. Changelog conforms, body carries Changelog:, todo marks Stage 3 done with 4–6 remaining.

(Verified at 8c9a0fb; re-checked at this head — the delta is only the #1606 merge, and cas get is unchanged.)

The conversion replaced several call sites with Io combinators — `pure(ok(…))`
became `pureOk`, `mapStep(…, unwrap)` became `unwrapStep`, a program tail
became `exitStep` — and left the old imports behind. Two were reported by the
code-quality review; the other nine are the same defect in the same change.

Changelog: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Delta at this head is clean: all 9 hunks are import statements, no executable line touched. The five +1/-1 files are named-import lists losing one name while the module stays imported (mapStep in fjs/ci/nix, pure in fjs/djs, mapStep in fjs/effects/memory/proof, ok in fjs/effects/node, ok in fjs/protocol/mcp/stdio); the four pure deletions drop a whole line. No side-effect risk — there are no bare import '...' statements anywhere in fjs/, and the two modules losing a direct importer contain only imports, comments and export const arrows, so nothing runs at load. npx tsc exit 0; 2925 pass / 0 fail; npm run cov exit 0 at 100/100/100.

The cas get finding is still open, unchanged — fjs/cas/cli/module.f.mjs:53 still does String(v) on the IoError tuple, so a failed cas get prints e: ioError,[object Object] instead of the ENOENT message. errorMessage(v) is the fix.

`cas get`'s failure branch stringified the channel error —
`errorExit('e: ' + String(v))` — which used to print the thrown `Error` and now
prints `ioError,[object Object]`. Reported in review; the diagnostic is the
whole value of that line.

Both handlers now end in `exitStep`, the exit-code policy this stage added, so
the message reaching the user is the host's own. `cas add`'s failure branch was
the same defect one step further along: it exited `1` with nothing on stderr at
all, and now reports too.

The proofs asserted only that *something* was written to stderr, which is what
let a stringified tuple pass. They now assert the message.

Changelog: none

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gTX18dhKRpQDAGoQWKiFb

Copy link
Copy Markdown
Contributor Author

Fixed in 63a1150 — thank you, that was a real regression and the probe caught exactly the hazard this stage is about: String(...) accepted the new shape without a word from tsc.

cas get now ends in exitStep(writeFromStream(path, x)) rather than errorMessage(v) inside the old ternary. Same message, and it drops the e: prefix so the handler reads like every other program tail this stage converted:

$ fjs cas get <unstored-hash> out
no such file or directory

cas add had the same defect one step further along, so it went with it: its failure branch was pure(1) — exit 1 with nothing on stderr. It is now exitStep(ioStep(added, hash => log(...))), which reports the failure and logs the hash on success.

The proofs are the reason this slipped: mainGetNotFound asserted only finalState.stderr.length !== 0, which a stringified tuple satisfies. Both it and mainAddMissing now assert the message itself.

npx tsc exit 0; fjs t 2925 pass / 0 fail; npm run cov exit 0 at 100/100/100.


Generated by Claude Code

@o2alexanderfedin o2alexanderfedin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix confirmed by running the real CLI — cas get for a hash absent from an empty store, HOME pointed at a temp dir:

origin/main: e: Error: ENOENT: no such file or directory, open '…/.cas/mb/mw/wn3xb2rb…', exit 1
this head: ENOENT: no such file or directory, open '…/.cas/mb/mw/wn3xb2rb…', exit 1

Code and path both reach the user again; only the redundant e: Error: prefix is gone. (At 8c9a0fb this printed e: ioError,[object Object].)

exitStep is the better shape than the errorMessage(v) I suggested — step(e, r => r[0] === 'error' ? errorExit(errorMessage(r[1])) : pure(0)) puts one shared policy in place of a per-command string.

Side effects checked: exitStep already existed in this PR and is used by cli, djs, website. The commit also routes cas add through it, which is an improvement — main exited 1 with empty stderr, head prints ENOENT: … / EISDIR: illegal operation on a directory, read. cas add (ok), cas list, cas list on an empty store, cas get (ok), bad-dest and bad-hash-format are byte-identical across both trees.

Swept for the same bug class: no other Result/IoError reaches String(…), ${}, + or JSON.stringify. The one candidate, fjs/emergent_testing/module.f.mjs:358, is the thrown test value from sandbox whose io channel is unwrapped upstream — shape unchanged.

npx tsc exit 0; fjs t → 2929 pass / 0 fail; npm run cov → 100/100/100, exit 0.

@sergey-shandar
sergey-shandar added this pull request to the merge queue Aug 16, 2026
Merged via the queue into main with commit a156385 Aug 16, 2026
19 checks passed
@sergey-shandar
sergey-shandar deleted the claude/io-effect-migration-stage-3 branch August 17, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants