From 0dffe551e34f621ef3fb3f5b3363c331da521800 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:22:28 +0000 Subject: [PATCH 1/6] effects: the operation vocabulary is not node's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `OpResult`, `IoError`, `IoErrorInfo`, `IoChannel`, `IoResult` and the `ioError` / `toIoError` / `isNotFound` constructors were declared in `effects/node`, but nothing in them names a host: "the runner cannot dispatch this" and "the host tried and failed" are the two ways any operation goes wrong, on any host. They move to `effects/` beside `NotImplemented`, which `OpResult` is defined in terms of. The misfiling already had a victim: `effects/memory/types.ts` — which has no host at all — imported `OpResult` from `../node/types.ts`. It now takes it from the layer it belongs to. `effects/node` re-exports every moved name, so the several dozen modules that reach for them through it are unchanged, and an operation's declaration still reads as one vocabulary. Groundwork for step 4 of emergent_testing/todo/share-browser-console-runner.md: a second host's operations cannot be typed while the types they are written in live in the first host's module. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/memory/types.ts | 2 +- fjs/effects/module.f.mjs | 55 +++++++++++++- fjs/effects/node/module.f.mjs | 56 +++----------- fjs/effects/node/proof.f.mjs | 52 +------------ fjs/effects/node/types.ts | 67 +++-------------- fjs/effects/proof.f.mjs | 70 ++++++++++++++++- fjs/effects/types.ts | 75 +++++++++++++++++-- .../todo/share-browser-console-runner.md | 21 +++++- 8 files changed, 232 insertions(+), 166 deletions(-) diff --git a/fjs/effects/memory/types.ts b/fjs/effects/memory/types.ts index 844dbb80c..9d3fb2834 100644 --- a/fjs/effects/memory/types.ts +++ b/fjs/effects/memory/types.ts @@ -6,7 +6,7 @@ import type { Phantom } from '../../types/phantom/types.ts' import type { Nominal } from '../../types/nominal/types.ts' -import type { OpResult } from '../node/types.ts' +import type { OpResult } from '../types.ts' /** Nominal brand version for memory keys. */ export type _MemKeyHash = '3f114fa6036a8da026b827f0c3e6d901f5e81ad9a320e431ccce31451892d286' diff --git a/fjs/effects/module.f.mjs b/fjs/effects/module.f.mjs index 382e75340..4685cfbe5 100644 --- a/fjs/effects/module.f.mjs +++ b/fjs/effects/module.f.mjs @@ -82,7 +82,7 @@ * @import { Fold } from '../types/function/operator/types.ts' * @import { Option } from '../types/option/types.ts' * @import { Result } from '../types/result/types.ts' - * @import { Commands, Effect, ErrOf, Func, MatchResult, NotImplemented, OkOf, Operation, OperationMap, PartialOperationMap } from './types.ts' + * @import { Commands, Effect, ErrOf, Func, IoChannel, IoError, IoErrorInfo, MatchResult, NotImplemented, OkOf, Operation, OperationMap, PartialOperationMap } from './types.ts' */ import { assert } from '../asserts/module.f.mjs' @@ -90,6 +90,59 @@ import { fold } from '../types/list/module.f.mjs' import { error, mapOk, ok } from '../types/result/module.f.mjs' import { at } from '../types/object/module.f.mjs' +/** + * Builds a normalized host error. The constructor exists so the shape is + * written once: every runner reports its failures through it, and a consumer + * matching on `'ioError'` knows what the payload holds. + * + * @type {(info: IoErrorInfo) => IoError} + */ +export const ioError = info => ['ioError', info] + +/** + * Normalizes a **thrown** value into an {@link IoError}: the OS error code when + * the host attached a string one, and a message that is the `Error`'s own or + * the value's string form. + * + * This is the boundary where an impure runner's `catch` becomes ordinary effect + * data. Nothing past it sees the thrown object, which is the point — a stack, a + * `cause`, and arbitrary own properties do not survive a wire hop, and a + * program that branched on them would be reading the host's implementation + * rather than the operation's contract. + * + * The `code` convention is node's in origin and not node's in reach: a browser + * `DOMException` carries a string `name` and not a `code`, so it normalizes + * through the message branch — correctly, since there is no OS code to report. + * + * @type {(e: unknown) => IoError} + */ +export const toIoError = e => { + const message = e instanceof Error ? e.message : String(e) + if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { + return ioError({ message }) + } + return ioError({ code: e.code, message }) +} + +/** + * True if `e` is a "file or directory does not exist" (`ENOENT`) error. + * + * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which + * {@link toIoError} keeps; the virtual interpreter reports the same code for + * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh + * store) while propagating genuine failures (permissions, corruption) rather + * than masking them. + * + * A {@link NotImplemented} is never "not found": a runner that cannot perform + * the operation has not looked for the path at all, so the two must not + * collapse into one benign branch — which is exactly what a bare `unknown` + * error channel used to allow. + * + * @type {(e: IoChannel) => boolean} + */ +export const isNotFound = ([tag, payload]) => + tag === 'ioError' && payload.code === 'ENOENT' + /** * Lifts an already-computed {@link Result} into an effect that performs no * command. diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index 5d1c6dddd..3ab265a7d 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -23,19 +23,21 @@ import { codePointListToString } from '../../text/utf16/module.f.mjs' import { reverse } from '../../types/list/module.f.mjs' import { length } from '../../types/bit_vec/module.f.mjs' import { error as resultError, ok as resultOk, unwrap } from '../../types/result/module.f.mjs' -import { do_, pure } from '../module.f.mjs' +import { do_, ioError, isNotFound, pure, toIoError } from '../module.f.mjs' import { mapStep as ioMapStep, pureError, pureOk, resultMapStep, resultStep, step as ioStep, } from '../module.f.mjs' /** - * Builds a normalized host error. The constructor exists so the shape is - * written once: every runner reports its failures through it, and a consumer - * matching on `'ioError'` knows what the payload holds. - * - * @type {(info: IoErrorInfo) => IoError} + * The host-error vocabulary — `ioError`, `toIoError`, `isNotFound` — is + * declared in [`../module.f.mjs`](../module.f.mjs) beside the effect + * representation, because none of it is node's: normalizing a thrown value and + * telling "the runner cannot" from "the host tried and failed" are what any + * host's interpreter does. Re-exported here so the modules that reach for them + * through the node module keep working, and so an operation's declaration and + * its failure constructor still read as one vocabulary. */ -export const ioError = info => ['ioError', info] +export { ioError, isNotFound, toIoError } /** * The host a {@link Listen} refuses. @@ -83,46 +85,6 @@ export const emptyHostError = ioError({ message: emptyHostMessage, }) -/** - * Normalizes a **thrown** value into an {@link IoError}: the OS error code when - * the host attached a string one, and a message that is the `Error`'s own or - * the value's string form. - * - * This is the boundary where an impure runner's `catch` becomes ordinary effect - * data. Nothing past it sees the thrown object, which is the point — a stack, a - * `cause`, and arbitrary own properties do not survive a wire hop, and a - * program that branched on them would be reading the host's implementation - * rather than the operation's contract. - * - * @type {(e: unknown) => IoError} - */ -export const toIoError = e => { - const message = e instanceof Error ? e.message : String(e) - if (typeof e !== 'object' || e === null || !('code' in e) || typeof e.code !== 'string') { - return ioError({ message }) - } - return ioError({ code: e.code, message }) -} - -/** - * True if `e` is a "file or directory does not exist" (`ENOENT`) error. - * - * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which - * {@link toIoError} keeps; the virtual interpreter reports the same code for - * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh - * store) while propagating genuine failures (permissions, corruption) rather - * than masking them. - * - * A {@link NotImplemented} is never "not found": a runner that cannot perform - * the operation has not looked for the path at all, so the two must not - * collapse into one benign branch — which is exactly what a bare `unknown` - * error channel used to allow. - * - * @type {(e: IoChannel) => boolean} - */ -export const isNotFound = ([tag, payload]) => - tag === 'ioError' && payload.code === 'ENOENT' - /** * `NodeOp`'s commands as data, so a runner that implements only part of them * can still tell an operation it lacks from a `Do` node whose `command` was diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index e04035da3..8808cc7d1 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -10,7 +10,7 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match } from "../module.f.mjs" import { mapStep, step as ioStep } from "../module.f.mjs" -import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, toIoError, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { both, errorMessage, errorSummary, exitStep, fetch, ioError, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.mjs" import { empty as listEmpty, nonEmpty as listNonEmpty } from "../list/module.f.mjs" import { emptyState, virtual } from "./virtual/module.f.mjs" @@ -50,56 +50,6 @@ const assertOk = (r, expected) => { } export const proof = { - // The one boundary where a runner's `catch` becomes effect data: whatever - // was thrown is reduced to a code (when the host attached a string one) - // and a message. - toIoError: { - error: () => { - assertIoMessage(toIoError(new Error('boom')), 'boom') - }, - withCode: () => { - const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, 'ENOENT', e) - assertEq(e[1].message, 'missing', e) - }, - // A thrown non-`Error` still normalizes: the value's string form is the - // message, and there is no code to carry. - string: () => { - const e = toIoError('plain') - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - assertEq(e[1].message, 'plain', e) - }, - null: () => { - assertIoMessage(toIoError(null), 'null') - }, - // An object whose `code` is not a string is not an OS error code, so it - // is dropped rather than carried as one. - nonStringCode: () => { - const e = toIoError({ code: 42 }) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - noCode: () => { - const e = toIoError({}) - assert(e[0] === 'ioError', e) - assertEq(e[1].code, undefined, e) - }, - }, - isNotFound: { - enoent: () => { - assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) - }, - otherCode: () => { - assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) - }, - // A runner that cannot perform the operation has not looked for the - // path at all, so a missing handler is never "not found". - notImplemented: () => { - assert(!isNotFound(['notImplemented', 'readFile'])) - }, - }, errorMessage: { io: () => { assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') diff --git a/fjs/effects/node/types.ts b/fjs/effects/node/types.ts index 886a045f3..522334f25 100644 --- a/fjs/effects/node/types.ts +++ b/fjs/effects/node/types.ts @@ -10,66 +10,21 @@ import type { MemOp } from '../memory/types.ts' import type { Nominal } from '../../types/nominal/types.ts' import type { Result } from '../../types/result/types.ts' import type { StringMap } from '../../types/object/types.ts' -import type { Effect, NotImplemented, Operation, ToAsyncOperationMap } from '../types.ts' +import type { + Effect, IoChannel, IoError, IoErrorInfo, IoResult, NotImplemented, OpResult, + Operation, ToAsyncOperationMap, +} from '../types.ts' import type { List } from '../list/types.ts' /** - * A host failure, normalized: whatever the runtime threw reduced to a - * serializable record. `code` is the OS error code when the host supplied one - * (`'ENOENT'`, `'EEXIST'`), absent otherwise. - * - * It is a tagged tuple for the same reason {@link NotImplemented} is — the two - * share an error channel, and the tag is what tells them apart. That - * distinction is the whole reason this type exists: with a bare `unknown` - * error, `NotImplemented | unknown` collapses to `unknown` and a program can no - * longer tell "this runner cannot do it" from "the host tried and failed". - * - * Normalizing also keeps the channel serializable. A thrown `Error` carries a - * stack, a `cause`, and arbitrary own properties; none of it survives a wire - * hop, and a runner in another process could not reproduce it. - */ -export type IoError = readonly['ioError', IoErrorInfo] - -export type IoErrorInfo = { - readonly code?: string - readonly message: string -} - -/** - * The result of an operation with no failures of its own: it either produces - * its value or reports that the runner does not implement it. - * - * Every operation's return type is a `Result`, including the ones that cannot - * fail on their own terms — an operation left on a raw contract would be a hole - * in the error channel, and a runner may omit a handler for any of them. - */ -export type OpResult = Result - -/** - * The error channel of anything that performs host IO: a normalized host - * failure, or the report that the runner does not implement the operation. - * - * It is one name rather than a union spelled at each site, and that is a - * migration property rather than brevity. An effect that does no IO *yet* is - * one added `readFile` away from doing some, and if each signature names its - * own errors, that one change walks up every enclosing signature — the failure - * mode that sank `throws` clauses elsewhere, where engineers eventually - * declared everything throwing rather than maintain the cascade. Declaring the - * standard channel once is that concession made deliberately: an IO-touching - * effect says it fails *the way node IO fails*, and gaining a new way to do so - * changes nothing above it. - * - * It is not a licence to widen. An operation with failures of its own extends - * the channel (`IoChannel | ParseError`), and a computation whose errors are - * genuinely narrower should say so — this is the default for IO, not a ceiling. - */ -export type IoChannel = NotImplemented | IoError - -/** - * The result of an operation that performs host IO: its value, a normalized - * host failure, or the missing-handler report. + * The vocabulary every operation is declared in — how a runner reports that it + * cannot dispatch, and how a host reports that it tried and failed — now lives + * in [`../types.ts`](../types.ts), beside {@link NotImplemented}, because none + * of it is node's. It is re-exported here so that the several dozen modules + * naming these through the node module keep doing so, and so a signature can go + * on reading as one vocabulary rather than two. */ -export type IoResult = Result +export type { IoChannel, IoError, IoErrorInfo, IoResult, OpResult } // all diff --git a/fjs/effects/proof.f.mjs b/fjs/effects/proof.f.mjs index 102ad602c..2968d8f13 100644 --- a/fjs/effects/proof.f.mjs +++ b/fjs/effects/proof.f.mjs @@ -1,12 +1,12 @@ /** - * @import { Effect, Func, Operation } from './types.ts' + * @import { Effect, Func, IoChannel, Operation } from './types.ts' * @import { Result } from '../types/result/types.ts' */ import { - catchStep, do_, foldStep, forEachStep, history, historyStep, mapStep, match, - partialMatch, pure, pureError, pureOk, resultMapStep, resultStep, runPure, step, - unwrapStep, + catchStep, do_, foldStep, forEachStep, history, historyStep, ioError, + isNotFound, mapStep, match, partialMatch, pure, pureError, pureOk, + resultMapStep, resultStep, runPure, step, toIoError, unwrapStep, } from './module.f.mjs' import { error, ok } from '../types/result/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' @@ -175,7 +175,69 @@ const checked = v => { */ const show = e => `${e}` +/** + * Asserts that a channel error is a host failure carrying `message`. Every + * runner reports through the same normalized `IoError`, so a proof names the + * message rather than the shape. + * + * @type {(e: IoChannel, message: string) => void} + */ +const assertIoMessage = (e, message) => { + assert(e[0] === 'ioError', e) + assertEq(e[1].message, message) +} + export const proof = { + // The one boundary where a runner's `catch` becomes effect data: whatever + // was thrown is reduced to a code (when the host attached a string one) + // and a message. + toIoError: { + error: () => { + assertIoMessage(toIoError(new Error('boom')), 'boom') + }, + withCode: () => { + const e = toIoError(Object.assign(new Error('missing'), { code: 'ENOENT' })) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, 'ENOENT', e) + assertEq(e[1].message, 'missing', e) + }, + // A thrown non-`Error` still normalizes: the value's string form is the + // message, and there is no code to carry. + string: () => { + const e = toIoError('plain') + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + assertEq(e[1].message, 'plain', e) + }, + null: () => { + assertIoMessage(toIoError(null), 'null') + }, + // An object whose `code` is not a string is not an OS error code, so it + // is dropped rather than carried as one. + nonStringCode: () => { + const e = toIoError({ code: 42 }) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + noCode: () => { + const e = toIoError({}) + assert(e[0] === 'ioError', e) + assertEq(e[1].code, undefined, e) + }, + }, + isNotFound: { + enoent: () => { + assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) + }, + otherCode: () => { + assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) + }, + // A runner that cannot perform the operation has not looked for the + // path at all, so a missing handler is never "not found". + notImplemented: () => { + assert(!isNotFound(['notImplemented', 'readFile'])) + }, + }, runPure: { ok: () => { assertPure(pure(ok(5)), ok(5)) diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index 4164e2db9..80662aaf6 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -21,11 +21,11 @@ import type { * `error(notImplemented(command))` through the command's own output — so an * operation whose return admitted no error would be a hole in that mechanism: * there would be nowhere to put the refusal. Every *host* operation already - * returned `OpResult<…>` or `IoResult<…>` when this constraint was added, and - * so did four of the six declared inside proofs — through a bare `Result` - * rather than either alias, since those two are node conveniences. The two in - * `./proof.f.mjs` returned a bare `number`, and the commit that added the rule - * rewrote them. + * returned {@link OpResult} or {@link IoResult} when this constraint was added, + * and so did four of the six declared inside proofs — through a bare `Result` + * rather than either alias, which were then declared in `./node/types.ts` and + * so read as node conveniences. The two in `./proof.f.mjs` returned a bare + * `number`, and the commit that added the rule rewrote them. * * It is also the latch the whole representation now rests on. An operation * *cannot* be declared infallible, so every {@link Effect} built from one has a @@ -65,6 +65,71 @@ export type Operation = */ export type NotImplemented = readonly['notImplemented', string] +/** + * The result of an operation with no failures of its own: it either produces + * its value or reports that the runner does not implement it. + * + * Every operation's return type is a `Result`, including the ones that cannot + * fail on their own terms — an operation left on a raw contract would be a hole + * in the error channel, and a runner may omit a handler for any of them. + */ +export type OpResult = Result + +/** + * A host failure, normalized: whatever the runtime threw reduced to a + * serializable record. `code` is the OS error code when the host supplied one + * (`'ENOENT'`, `'EEXIST'`), absent otherwise. + * + * It is a tagged tuple for the same reason {@link NotImplemented} is — the two + * share an error channel, and the tag is what tells them apart. That + * distinction is the whole reason this type exists: with a bare `unknown` + * error, `NotImplemented | unknown` collapses to `unknown` and a program can no + * longer tell "this runner cannot do it" from "the host tried and failed". + * + * Normalizing also keeps the channel serializable. A thrown `Error` carries a + * stack, a `cause`, and arbitrary own properties; none of it survives a wire + * hop, and a runner in another process could not reproduce it. + */ +export type IoError = readonly['ioError', IoErrorInfo] + +export type IoErrorInfo = { + readonly code?: string + readonly message: string +} + +/** + * The error channel of anything that performs host IO: a normalized host + * failure, or the report that the runner does not implement the operation. + * + * It is one name rather than a union spelled at each site, and that is a + * migration property rather than brevity. An effect that does no IO *yet* is + * one added `readFile` away from doing some, and if each signature names its + * own errors, that one change walks up every enclosing signature — the failure + * mode that sank `throws` clauses elsewhere, where engineers eventually + * declared everything throwing rather than maintain the cascade. Declaring the + * standard channel once is that concession made deliberately: an IO-touching + * effect says it fails *the way host IO fails*, and gaining a new way to do so + * changes nothing above it. + * + * It is not a licence to widen. An operation with failures of its own extends + * the channel (`IoChannel | ParseError`), and a computation whose errors are + * genuinely narrower should say so — this is the default for IO, not a ceiling. + * + * **It is not node's, though it was declared there.** Nothing in either half + * names a host: a runner that cannot dispatch and a host that tried and failed + * are the two ways any operation goes wrong, on any host. Living in + * `./node/types.ts` meant that `./memory/types.ts` — which has no host at + * all — reached into the node module for {@link OpResult}, and that a second + * host's operations could not be typed without doing the same. + */ +export type IoChannel = NotImplemented | IoError + +/** + * The result of an operation that performs host IO: its value, a normalized + * host failure, or the missing-handler report. + */ +export type IoResult = Result + /** * An `Effect` is the raw value: a {@link Pure} thunk yielding * `Result`, or a {@link Do} node describing a command to perform. It is diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 0bb159d66..2697d3976 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -129,8 +129,27 @@ and is reviewable without the next one. `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a shared module that `effects/node` re-exports unchanged, so nothing has to move with them. + + **The vocabulary went first, and it was not speculative.** Before an + operation can move, the types it is *declared in* have to have a home: + `OpResult`, `IoError`, `IoErrorInfo`, `IoChannel`, `IoResult` and the + `ioError`/`toIoError`/`isNotFound` constructors were all in + `effects/node`, and none of them names a host — "the runner cannot + dispatch" and "the host tried and failed" are how *any* operation goes + wrong. That misfiling already had a victim: `effects/memory/types.ts`, + which has no host at all, imported `OpResult` from `../node/types.ts`. + So that move is separation of concerns with a consumer today + ([DESIGN.md §4](../../../DESIGN.md)), not an extraction on the promise of + one — which is the test the operations themselves have yet to pass, and + why they wait for step 5. `effects/node` re-exports every moved name, so + the several dozen modules that reach for them through it are untouched. - [ ] **5. A browser interpreter** for exactly those operations, with no - scheduling policy of its own. + scheduling policy of its own. This is also what earns step 4's *operation* + move its second consumer: until a second host implements `now`, `sandbox`, + `await` and `all`, moving them out of `effects/node` makes nothing shorter + or clearer, and DESIGN.md §4 says to extract at the second real consumer + rather than before it. The two are therefore one design in two commits, + not one step deferred. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the From e67bb52b7346f45c00408a3b7d923f29b0204e49 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:31:22 +0000 Subject: [PATCH 2/6] effects: isNotFound is node's after all, and say what moved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found `effects/todo/node-module-layering.md`, an open design that had already decided two of these questions. It was right about one and outdated about the other, so this reconciles both rather than leaving contradictory guidance for whoever implements it. `isNotFound` moves back to `effects/node`, with its proofs. It reads `ENOENT`, a POSIX filesystem code a host without a filesystem never reports, so it is a node predicate — "none of it is node's" was overreach for that one function. Being about a host failure does not make a thing host-agnostic; being about no host in particular does. The issue's other ruling — that `IoResult` must not move to the core — is overturned in the file with the reason: it was reasoned without a second host, and a browser interpreter cannot declare `fetch` or `import` without that alias existing somewhere shared. Its stated test ("not an effect constructor or combinator") also did not describe the file it was applied to, where `NotImplemented` already lives. The one-site `fjs/media/type` cleanup it proposed survives and stays open. Also records why a re-export here is not the "no shims behind" case that issue rules out: node's own operations are declared in these types, so it re-exports what it genuinely uses. Changelog entry added — the core module gains public exports even though no old import path changed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1753.md | 4 ++ fjs/effects/module.f.mjs | 19 ------ fjs/effects/node/module.f.mjs | 46 +++++++++++--- fjs/effects/node/proof.f.mjs | 15 ++++- fjs/effects/proof.f.mjs | 19 +----- fjs/effects/todo/node-module-layering.md | 79 +++++++++++++++--------- 6 files changed, 107 insertions(+), 75 deletions(-) create mode 100644 changelog/unreleased/1753.md diff --git a/changelog/unreleased/1753.md b/changelog/unreleased/1753.md new file mode 100644 index 000000000..e46b9e44f --- /dev/null +++ b/changelog/unreleased/1753.md @@ -0,0 +1,4 @@ +- `effects`: the vocabulary every operation is declared in — `OpResult`, + `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, and the `ioError` / + `toIoError` constructors — is now importable from the core module, not only + through `effects/node`, which re-exports it unchanged diff --git a/fjs/effects/module.f.mjs b/fjs/effects/module.f.mjs index 4685cfbe5..1440876a1 100644 --- a/fjs/effects/module.f.mjs +++ b/fjs/effects/module.f.mjs @@ -124,25 +124,6 @@ export const toIoError = e => { return ioError({ code: e.code, message }) } -/** - * True if `e` is a "file or directory does not exist" (`ENOENT`) error. - * - * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which - * {@link toIoError} keeps; the virtual interpreter reports the same code for - * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh - * store) while propagating genuine failures (permissions, corruption) rather - * than masking them. - * - * A {@link NotImplemented} is never "not found": a runner that cannot perform - * the operation has not looked for the path at all, so the two must not - * collapse into one benign branch — which is exactly what a bare `unknown` - * error channel used to allow. - * - * @type {(e: IoChannel) => boolean} - */ -export const isNotFound = ([tag, payload]) => - tag === 'ioError' && payload.code === 'ENOENT' - /** * Lifts an already-computed {@link Result} into an effect that performs no * command. diff --git a/fjs/effects/node/module.f.mjs b/fjs/effects/node/module.f.mjs index 3ab265a7d..e3bd8e3f6 100644 --- a/fjs/effects/node/module.f.mjs +++ b/fjs/effects/node/module.f.mjs @@ -23,21 +23,26 @@ import { codePointListToString } from '../../text/utf16/module.f.mjs' import { reverse } from '../../types/list/module.f.mjs' import { length } from '../../types/bit_vec/module.f.mjs' import { error as resultError, ok as resultOk, unwrap } from '../../types/result/module.f.mjs' -import { do_, ioError, isNotFound, pure, toIoError } from '../module.f.mjs' +import { do_, ioError, pure, toIoError } from '../module.f.mjs' import { mapStep as ioMapStep, pureError, pureOk, resultMapStep, resultStep, step as ioStep, } from '../module.f.mjs' /** - * The host-error vocabulary — `ioError`, `toIoError`, `isNotFound` — is - * declared in [`../module.f.mjs`](../module.f.mjs) beside the effect - * representation, because none of it is node's: normalizing a thrown value and - * telling "the runner cannot" from "the host tried and failed" are what any - * host's interpreter does. Re-exported here so the modules that reach for them - * through the node module keep working, and so an operation's declaration and - * its failure constructor still read as one vocabulary. + * `ioError` and `toIoError` are declared in + * [`../module.f.mjs`](../module.f.mjs) beside the effect representation, + * because neither is node's: normalizing a thrown value into serializable + * effect data is what any host's interpreter does at its `catch`. They are + * re-exported here so the modules that reach for them through the node module + * keep working, and so an operation's declaration and its failure constructor + * still read as one vocabulary. + * + * {@link isNotFound} stayed, and the difference is the test for where any of + * this belongs: it reads `ENOENT`, a POSIX filesystem code that no browser + * ever reports. Being about a *host failure* does not make a thing + * host-agnostic — being about no host in particular does. */ -export { ioError, isNotFound, toIoError } +export { ioError, toIoError } /** * The host a {@link Listen} refuses. @@ -85,6 +90,29 @@ export const emptyHostError = ioError({ message: emptyHostMessage, }) +/** + * True if `e` is a "file or directory does not exist" (`ENOENT`) error. + * + * Node's filesystem rejections are `Error`s carrying `code: 'ENOENT'`, which + * {@link toIoError} keeps; the virtual interpreter reports the same code for + * absent paths. Lets callers swallow only the missing-path case (e.g. a fresh + * store) while propagating genuine failures (permissions, corruption) rather + * than masking them. + * + * A {@link NotImplemented} is never "not found": a runner that cannot perform + * the operation has not looked for the path at all, so the two must not + * collapse into one benign branch — which is exactly what a bare `unknown` + * error channel used to allow. + * + * **It belongs to this layer, unlike the constructors above.** `ENOENT` is a + * POSIX filesystem code; a host without a filesystem never reports one, so a + * shared `isNotFound` would be a node predicate wearing a host-agnostic name. + * + * @type {(e: IoChannel) => boolean} + */ +export const isNotFound = ([tag, payload]) => + tag === 'ioError' && payload.code === 'ENOENT' + /** * `NodeOp`'s commands as data, so a runner that implements only part of them * can still tell an operation it lacks from a `Do` node whose `command` was diff --git a/fjs/effects/node/proof.f.mjs b/fjs/effects/node/proof.f.mjs index 8808cc7d1..bd094f375 100644 --- a/fjs/effects/node/proof.f.mjs +++ b/fjs/effects/node/proof.f.mjs @@ -10,7 +10,7 @@ import { empty, isVec, uint, vec, vec8 } from "../../types/bit_vec/module.f.mjs" import { utf8, utf8ToString } from "../../text/module.f.mjs" import { match } from "../module.f.mjs" import { mapStep, step as ioStep } from "../module.f.mjs" -import { both, errorMessage, errorSummary, exitStep, fetch, ioError, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" +import { both, errorMessage, errorSummary, exitStep, fetch, ioError, isNotFound, mkdir, now, readdir, readFile, readUtf8File, rm, sandbox, writeFile, writeUtf8File, rename, readBytes, randomInt, writeFromStream, usesInlineTestContext, versionLessThan } from "./module.f.mjs" import { create as memCreate, read as memRead, write as memWrite } from "../memory/module.f.mjs" import { empty as listEmpty, nonEmpty as listNonEmpty } from "../list/module.f.mjs" import { emptyState, virtual } from "./virtual/module.f.mjs" @@ -50,6 +50,19 @@ const assertOk = (r, expected) => { } export const proof = { + isNotFound: { + enoent: () => { + assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) + }, + otherCode: () => { + assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) + }, + // A runner that cannot perform the operation has not looked for the + // path at all, so a missing handler is never "not found". + notImplemented: () => { + assert(!isNotFound(['notImplemented', 'readFile'])) + }, + }, errorMessage: { io: () => { assertEq(errorMessage(ioError({ message: 'disk full' })), 'disk full') diff --git a/fjs/effects/proof.f.mjs b/fjs/effects/proof.f.mjs index 2968d8f13..2f9373d7d 100644 --- a/fjs/effects/proof.f.mjs +++ b/fjs/effects/proof.f.mjs @@ -4,9 +4,9 @@ */ import { - catchStep, do_, foldStep, forEachStep, history, historyStep, ioError, - isNotFound, mapStep, match, partialMatch, pure, pureError, pureOk, - resultMapStep, resultStep, runPure, step, toIoError, unwrapStep, + catchStep, do_, foldStep, forEachStep, history, historyStep, mapStep, + match, partialMatch, pure, pureError, pureOk, resultMapStep, resultStep, + runPure, step, toIoError, unwrapStep, } from './module.f.mjs' import { error, ok } from '../types/result/module.f.mjs' import { assert, assertEq, todo } from '../asserts/module.f.mjs' @@ -225,19 +225,6 @@ export const proof = { assertEq(e[1].code, undefined, e) }, }, - isNotFound: { - enoent: () => { - assert(isNotFound(ioError({ code: 'ENOENT', message: 'no such file or directory' }))) - }, - otherCode: () => { - assert(!isNotFound(ioError({ code: 'EACCES', message: 'permission denied' }))) - }, - // A runner that cannot perform the operation has not looked for the - // path at all, so a missing handler is never "not found". - notImplemented: () => { - assert(!isNotFound(['notImplemented', 'readFile'])) - }, - }, runPure: { ok: () => { assertPure(pure(ok(5)), ok(5)) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 5c4821075..414f578fb 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -54,7 +54,8 @@ provides*. Proposed destinations: | `fjs/effects/sandbox/module.f.mjs` | `Sandbox`, `SandboxResult`, `sandbox`, `Await`, `awaitIfPromise` — the "run foreign code and observe what happened" pair | | `fjs/effects/console/module.f.mjs` | `Read`, `Write`, `ReadConsoles`, `WriteConsoles`, `Console`, `log`, `error`, `readLine`, `errorExit`, and a **new named `Std`** (see below) | | `fjs/effects/test/module.f.mjs` | `Test`, `TestFn`, `TestContext`, `test` — registration with an external framework, not I/O | -| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Fetch`, `Import`, `Forever`, `Now`, `RandomInt`, `IoResult`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Fetch`, `Import`, `Forever`, `Now`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| already moved to `fjs/effects` | `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, `ioError`, `toIoError` — the vocabulary every operation is declared in; `effects/node` re-exports them (see the judgement call below) | `NodeOp` stays where it is and keeps unioning every family — it is the *runner's* op-set, which is legitimately "everything this host can do", and both @@ -67,34 +68,37 @@ Judgement calls worth deciding explicitly rather than by accident: - **`Now` / `RandomInt` stay.** They are ambient host capabilities with no cross-runtime abstraction to gain, and no consumer outside `fjs/cas` and the interpreters. Moving them would be motion without a reader benefit. -- **`isNotFound` stays.** It encodes Node's `ENOENT` shape specifically; that - *is* a Node concern. -- **`IoResult` stays too — pure consumers should stop importing it instead.** - An earlier draft of this issue moved it to the effects core, on the reasoning - that core already imports `Result` so the move costs no new dependency. That - reasoning picks a destination by convenience rather than by concern, and the - destination is wrong on its own terms: `Result` is not an effect - constructor or combinator, so moving it would swap Node coupling for - core-effects coupling and leave a non-effect type in the effects core. - `fjs/types/result` is not the answer either — the *name* is about the host I/O - boundary ("the error is whatever the host threw"), and a generic types module - should not mint I/O vocabulary. - - Read the other way, `IoResult` is exactly a Node-layer contract and belongs - beside the operations it describes. The fix for a **pure** consumer is to - spell the underlying type, not to relocate the alias: - `fjs/media/type/module.f.mjs:45` imports `IoResult` from - `../../effects/node/types.ts` purely to write `IoResult` and - `IoResult`; writing `Result` from - `fjs/types/result` says the same thing and drops the `effects/node` import - **entirely**, which is a better outcome than moving where it points. - [fold-stream-combinator](./fold-stream-combinator.md) reached the same - conclusion independently for `fjs/effects/list` — its `Result`-spelled - signature is the right design, not the workaround that issue calls it. - - This is an independent, one-site cleanup: it neither depends on nor supports - the moves below. Listed here because that is where the wrong answer was - written down; it can land on its own. +- **`isNotFound` stays, and this was tested.** It encodes `ENOENT` + specifically — a POSIX filesystem code that a host without a filesystem never + reports — so it *is* a Node-layer concern. A change that moved it to the core + along with the error vocabulary was reviewed against this line and reverted + on it: being about a *host failure* does not make a thing host-agnostic, + being about no host in particular does. +- **`IoResult` moved to the effects core, and this issue was wrong to say it + should not.** The reasoning here was that `Result` is not an + effect constructor or combinator, so the core is the wrong home and pure + consumers should spell the underlying type instead. What that reasoning did + not have was a **second host**. `IoResult` is not "exactly a Node-layer + contract": it is the shape every host's IO operations answer in, and a + browser interpreter cannot declare `fetch` or `import` without it. The same + goes for `OpResult`, `IoChannel`, `IoError` and `IoErrorInfo`, which this + issue never listed — `OpResult` is `Result`, defined + purely in terms of a type the core already owns, and `effects/memory` (no + host at all) was importing it from `effects/node`. + + The "not an effect constructor or combinator" test also did not describe the + file it was applied to: `NotImplemented` already lives in the core and is + neither. What the core holds is the vocabulary an operation is *declared* + in, and this is that. + + The one-site cleanup this bullet also proposed is still worth doing and is + now the task below: `fjs/media/type/module.f.mjs` imports `IoResult` only to + spell two signatures, and `Result` from `fjs/types/result` says + the same thing without reaching into the effects package at all. + [fold-stream-combinator](./fold-stream-combinator.md) reached that conclusion + independently for `fjs/effects/list`. That a pure consumer should not name an + IO alias and that a *second host* needs one to exist somewhere shared are + both true; the old bullet collapsed them into one answer. - **`Test` goes to an effects module, not to `fjs/emergent_testing`.** `emergent_testing` looks like the natural owner — it is the only consumer of `test` and the module that defines what a test *is* — but putting the @@ -177,6 +181,16 @@ Judgement calls worth deciding explicitly rather than by accident: - **Every move is a breaking change** to an import path. Per `AGENTS.md`, do one concern per PR, update every importer in the same PR, and prefix the CHANGELOG entry with `**BREAKING CHANGES:**`. Do not leave re-export shims behind. + + **The vocabulary move is the one exception, and for a reason that does not + generalize.** A re-export is a shim when it keeps a *dead* coupling alive — + which is the case for every move in the table above, where the whole goal is + that `fjs/text/sgr` stops naming `effects/node` at all. It is not the case + for `IoChannel` and its siblings: node's own operations are declared in + them, so `effects/node` re-exporting what it genuinely uses keeps one + vocabulary readable at one import rather than preserving a coupling anyone + wants gone. That is why that move was additive and needed no importer churn, + and why the moves below still need theirs. - **The obsolete Playwright adapter is already gone.** This task must preserve only the process-side `TestContext` fields that still have consumers. It must not use relocation as a reason to revive the Playwright engine, context, @@ -191,9 +205,14 @@ Judgement calls worth deciding explicitly rather than by accident: ### Tasks +- [x] Move the operation vocabulary (`OpResult`, `IoChannel`, `IoError`, + `IoErrorInfo`, `IoResult`, `ioError`, `toIoError`) to `fjs/effects`, + with `effects/node` re-exporting it and `effects/memory` taking + `OpResult` from the core. `isNotFound` stayed — see the judgement calls. - [ ] Independent of the moves: replace `fjs/media/type`'s `IoResult` import with `Result` from `fjs/types/result`, dropping its - `effects/node` import. `IoResult` itself does **not** move. + `effects` import — a pure consumer should not name an IO alias, whichever + module the alias lives in. - [ ] Move `All` / `all` / `allOk` / `both` to `fjs/effects/all/module.f.mjs`. `allOk` is the ok-channel wrapper over `all` and belongs with it; [allvoid-combinator](./allvoid-combinator.md) builds on it, so leaving it From d66ae39eb5fe39c84f747206e5c3622f384d1a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:35:15 +0000 Subject: [PATCH 3/6] todo: isNotFound is the boundary marker, not part of the move The step-4 note was written before review established that isNotFound belongs in effects/node, and still listed it among the moved names under the claim that none of them names a host. It now records the opposite, and uses it as the test to apply to each operation the remaining move covers rather than moving the list wholesale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- .../todo/share-browser-console-runner.md | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 2697d3976..98253e4ae 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -133,16 +133,26 @@ and is reviewable without the next one. **The vocabulary went first, and it was not speculative.** Before an operation can move, the types it is *declared in* have to have a home: `OpResult`, `IoError`, `IoErrorInfo`, `IoChannel`, `IoResult` and the - `ioError`/`toIoError`/`isNotFound` constructors were all in - `effects/node`, and none of them names a host — "the runner cannot - dispatch" and "the host tried and failed" are how *any* operation goes - wrong. That misfiling already had a victim: `effects/memory/types.ts`, - which has no host at all, imported `OpResult` from `../node/types.ts`. - So that move is separation of concerns with a consumer today + `ioError`/`toIoError` constructors were all in `effects/node`, and none + of them names a host — "the runner cannot dispatch" and "the host tried + and failed" are how *any* operation goes wrong. That misfiling already + had a victim: `effects/memory/types.ts`, which has no host at all, + imported `OpResult` from `../node/types.ts`. So that move is separation + of concerns with a consumer today ([DESIGN.md §4](../../../DESIGN.md)), not an extraction on the promise of one — which is the test the operations themselves have yet to pass, and why they wait for step 5. `effects/node` re-exports every moved name, so the several dozen modules that reach for them through it are untouched. + + **`isNotFound` stayed, and it is the boundary marker for this step.** It + reads `ENOENT`, a POSIX filesystem code a browser never reports, so it is + a node predicate however much it looks like the constructors beside it. + Being about a *host failure* does not make a thing host-agnostic; being + about no host in particular does. Apply that test to each operation below + rather than moving the list wholesale — + [node-module-layering](../../effects/todo/node-module-layering.md) is + where those rulings live, and it already declines to move `Now` and + `RandomInt` for a related reason. - [ ] **5. A browser interpreter** for exactly those operations, with no scheduling policy of its own. This is also what earns step 4's *operation* move its second consumer: until a second host implements `now`, `sandbox`, From f80ca12ca522715874ce54c6a5b6e7dec057ffc3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:40:43 +0000 Subject: [PATCH 4/6] =?UTF-8?q?todo:=20now,=20fetch=20and=20import=20are?= =?UTF-8?q?=20unsettled=20=E2=80=94=20say=20so=20in=20both=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cross-reference added in the last commit made node-module-layering.md read as the source of truth, but the two files disagree: it keeps Now, Fetch and Import in effects/node on a reader-benefit argument, while share-browser-console-runner's step 4 lists all three as moving. Whichever a later reader opened first would have looked authoritative. Neither was written knowing the fact that decides it — which operations the step-5 browser interpreter actually implements — so both now record the disagreement, name that as what settles it, and require step 5 to update both in one change. The expectation, not a ruling: now and import move (a browser proof run needs a clock and dynamic import), fetch stays (nothing in the shared runner performs one). all, await and sandbox were never in dispute; both files move them. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/node-module-layering.md | 26 ++++++++++++++--- .../todo/share-browser-console-runner.md | 29 ++++++++++++++++--- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/fjs/effects/todo/node-module-layering.md b/fjs/effects/todo/node-module-layering.md index 414f578fb..81acbb6f2 100644 --- a/fjs/effects/todo/node-module-layering.md +++ b/fjs/effects/todo/node-module-layering.md @@ -54,7 +54,8 @@ provides*. Proposed destinations: | `fjs/effects/sandbox/module.f.mjs` | `Sandbox`, `SandboxResult`, `sandbox`, `Await`, `awaitIfPromise` — the "run foreign code and observe what happened" pair | | `fjs/effects/console/module.f.mjs` | `Read`, `Write`, `ReadConsoles`, `WriteConsoles`, `Console`, `log`, `error`, `readLine`, `errorExit`, and a **new named `Std`** (see below) | | `fjs/effects/test/module.f.mjs` | `Test`, `TestFn`, `TestContext`, `test` — registration with an external framework, not I/O | -| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Fetch`, `Import`, `Forever`, `Now`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| stays in `fjs/effects/node` | `Fs` and its members, `Http`, `Forever`, `RandomInt`, `isNotFound`, `Env`, `Engine`, `NodeOp`, `NodeProgramOptions`, `Program`, `NodeProgram`, `NodeOperationMap` | +| unsettled | `Now`, `Fetch`, `Import` — this issue and share-browser-console-runner's step 4 disagree; step 5 decides (see the judgement call below) | | already moved to `fjs/effects` | `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, `ioError`, `toIoError` — the vocabulary every operation is declared in; `effects/node` re-exports them (see the judgement call below) | `NodeOp` stays where it is and keeps unioning every family — it is the @@ -65,9 +66,26 @@ union that names them all does not. Judgement calls worth deciding explicitly rather than by accident: -- **`Now` / `RandomInt` stay.** They are ambient host capabilities with no - cross-runtime abstraction to gain, and no consumer outside `fjs/cas` and the - interpreters. Moving them would be motion without a reader benefit. +- **`RandomInt` stays.** An ambient host capability with no cross-runtime + abstraction to gain and no consumer outside `fjs/cas` and the interpreters. + Moving it would be motion without a reader benefit. +- **`Now`, `Fetch` and `Import` are unsettled, and step 5 of + [share-browser-console-runner](../../emergent_testing/todo/share-browser-console-runner.md) + decides them.** This issue put all three in the "stays" row on the reader-benefit + argument above; that issue's step 4 lists `now`, `fetch` and `import` among the + operations to move. Both were written without the fact that settles it — **which + operations a browser interpreter actually implements** — so neither ruling is + authoritative and the disagreement is recorded here rather than resolved by + whichever file a later reader opens first. + + The test to apply is the one `isNotFound` failed: not "does a browser also + have one of these", but "is this operation about no host in particular". By + that test `now` and `import` look likely to move — a browser proof run needs a + clock and dynamic import, so step 5 gives them a second implementer — and + `fetch` looks likely to stay, since nothing in the shared runner performs one + and DESIGN.md §4 extracts at the second *real* consumer, not the second + possible one. Those are expectations, not rulings: whichever way step 5 goes, + it updates both files in the same change. - **`isNotFound` stays, and this was tested.** It encodes `ENOENT` specifically — a POSIX filesystem code that a host without a filesystem never reports — so it *is* a Node-layer concern. A change that moved it to the core diff --git a/fjs/emergent_testing/todo/share-browser-console-runner.md b/fjs/emergent_testing/todo/share-browser-console-runner.md index 98253e4ae..1f6691eba 100644 --- a/fjs/emergent_testing/todo/share-browser-console-runner.md +++ b/fjs/emergent_testing/todo/share-browser-console-runner.md @@ -126,9 +126,22 @@ and is reviewable without the next one. rule they rest on is in [browser testing](browser-testing.md). - [ ] **4. Common effects.** Move the host-independent operations (`all`, - `await`, `fetch`, `import`, `now`, `sandbox`) out of `effects/node` into a - shared module that `effects/node` re-exports unchanged, so nothing has to - move with them. + `await`, `sandbox`, and whichever of `now`, `fetch` and `import` survive + the test below) out of `effects/node` into a shared module that + `effects/node` re-exports unchanged, so nothing has to move with them. + + **Three of that list are unsettled, and this step does not get to assume + them.** `all`, `await` and `sandbox` are agreed: + [node-module-layering](../../effects/todo/node-module-layering.md) moves + them too. But that issue keeps `Now`, `Fetch` and `Import` in + `effects/node` on a reader-benefit argument, and this step was written + listing all three as moving. Neither was written knowing the fact that + decides it — which operations the step-5 interpreter actually implements — + so step 5 settles them and updates both files in the same change. The + expectation recorded there: `now` and `import` move (a browser proof run + needs a clock and dynamic import), `fetch` stays (nothing in the shared + runner performs one, and DESIGN.md §4 extracts at the second *real* + consumer). **The vocabulary went first, and it was not speculative.** Before an operation can move, the types it is *declared in* have to have a home: @@ -155,11 +168,19 @@ and is reviewable without the next one. `RandomInt` for a related reason. - [ ] **5. A browser interpreter** for exactly those operations, with no scheduling policy of its own. This is also what earns step 4's *operation* - move its second consumer: until a second host implements `now`, `sandbox`, + move its second consumer: until a second host implements `sandbox`, `await` and `all`, moving them out of `effects/node` makes nothing shorter or clearer, and DESIGN.md §4 says to extract at the second real consumer rather than before it. The two are therefore one design in two commits, not one step deferred. + + **Its operation set is also the ruling** on `now`, `fetch` and `import`, + which step 4 and + [node-module-layering](../../effects/todo/node-module-layering.md) + currently disagree about. What this interpreter implements is what has a + second consumer; what it does not implement stays in `effects/node` until + something needs it. Write the answer into both files in this step's own + change, so neither is left asserting what the other denies. - [x] **6. One reporter.** The event stream — a leaf landed, a run ended — that both hosts subscribe to. Step 2 gave them the *value*; this gave them the seam it travels through. `Reporter.result` now receives the From 61d8d47425157fdd9e27996e6f93d8c06f679366 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:55:27 +0000 Subject: [PATCH 5/6] effects: IoChannel is not the node standard, in the file that says so MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the leftover: `Effect`'s doc still called `IoChannel` "the node standard of NotImplemented | IoError" — in the file this change promotes to the host-agnostic layer, under a paragraph saying the vocabulary is not node's. The two corrections the PR body listed did not cover it. Also trims the changelog entry to the ~250-character guideline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- changelog/unreleased/1753.md | 7 +++---- fjs/effects/types.ts | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/changelog/unreleased/1753.md b/changelog/unreleased/1753.md index e46b9e44f..1aa8fe757 100644 --- a/changelog/unreleased/1753.md +++ b/changelog/unreleased/1753.md @@ -1,4 +1,3 @@ -- `effects`: the vocabulary every operation is declared in — `OpResult`, - `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult`, and the `ioError` / - `toIoError` constructors — is now importable from the core module, not only - through `effects/node`, which re-exports it unchanged +- `effects`: `OpResult`, `IoChannel`, `IoError`, `IoErrorInfo`, `IoResult` and + the `ioError` / `toIoError` constructors are now importable from the core + module, not only through `effects/node`, which re-exports them unchanged diff --git a/fjs/effects/types.ts b/fjs/effects/types.ts index 649aced21..974a02db1 100644 --- a/fjs/effects/types.ts +++ b/fjs/effects/types.ts @@ -148,8 +148,8 @@ export type IoResult = Result * **`E` defaults to {@link NotImplemented}**, the one error every operation can * answer with, so the common case is written `Effect`. An * operation's own failures extend the channel — `Effect`, that alias being the node standard of - * `NotImplemented | IoError`. + * IoChannel>`, that alias being the standard {@link IoChannel} of + * `NotImplemented | IoError` that any host's IO answers in. * * **`Effect` is a claim, not an absence.** It says this code * absorbs its own failures *here* — an MCP handler turning one into a JSON-RPC From d91518d3d7aee6ed8c0e651b40a87835f8127421 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 15:59:39 +0000 Subject: [PATCH 6/6] todo: the migration record said IoError lives in effects/node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io-effect-migration.md is done but deliberately kept, and two live documents cite it as the design record — so its statement that IoError sits "in fjs/effects/node/types.ts beside the operations it belongs to" is guidance, not just history, and this change made it false. It now records both: that the migration put them there, which was true while node's were the only operations there were, and what overturned it — effects/memory importing OpResult from the node module, and a second host unable to declare an operation without doing the same. Nothing it says about their shape or use changed, and isNotFound stayed behind. Swept the rest of the markdown for the same claim: the remaining mentions are past-tense history, statements about operations (still in node), or a released changelog. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016PyLwDNkPQM1uD1Tg5ApBg --- fjs/effects/todo/io-effect-migration.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/fjs/effects/todo/io-effect-migration.md b/fjs/effects/todo/io-effect-migration.md index ad398885d..5ff901367 100644 --- a/fjs/effects/todo/io-effect-migration.md +++ b/fjs/effects/todo/io-effect-migration.md @@ -303,12 +303,23 @@ from `Result` — for one that performs host IO (`Result`). `IoError` is `readonly['ioError', { code?, message }]`, a tagged tuple beside -`NotImplemented` so the shared channel stays discriminable, in -`fjs/effects/node/types.ts` beside the operations it belongs to. `toIoError` +`NotImplemented` so the shared channel stays discriminable. `toIoError` normalizes a thrown host value at the one boundary where an impure runner catches; the virtual runner reports the same shape, so a proof against the virtual filesystem stays evidence about the real one. +**Both, and the two aliases above, have since moved to `fjs/effects/types.ts`.** +This migration put them in `fjs/effects/node/types.ts`, "beside the operations +they belong to", which was true while node's were the only operations there +were. What overturned it is a second host: `effects/memory` — no host at +all — was importing `OpResult` from the node module, and a browser +interpreter could not declare an operation without doing the same. `effects/node` +re-exports all of them, so nothing this record describes about their *shape* or +their use has changed. `isNotFound` stayed behind, being about `ENOENT` +specifically. See +[node-module-layering](./node-module-layering.md), which owns that question +now. + **`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 it never reported. Promoting