Effect boundaries
Status: Implemented (R0b EF2-P0, decision). The
maia-effect ESLint rules
run at error severity in every ESLint config, and the code that predates them
sits in a shrink-only baseline. The per-package EF2 migrations in
the roadmap
only shrink it. The repository rules
own the requirement; this page says what the lint checks and what to write
instead.
Scope. Application code: every file in a workspace src/ (apps/*,
packages/*, services/*) except tests (*.test.*, *.spec.*), testing/
support and declaration files. Package scripts/ and
repository tooling are out of scope (Effect applies there only to scripts that
orchestrate processes, by review). Tests have their own rules.
| Rule | Reports | Write instead |
|---|---|---|
promise-orchestration | new Promise, Promise.all/race/allSettled/any/withResolvers/try, async functions, await | Effect.fn, Effect.gen, Effect.all, Effect.race, Effect.callback |
restricted-imports | node:child_process, node:fs, fs/promises, node:http(s), node:events, xstate, jsonc-parser | ChildProcess (effect/process), FileSystem, HttpClient and HttpServer (effect/http), PubSub/Queue/Stream |
raw-globals | fetch, setTimeout, setInterval, queueMicrotask | HttpClient, Effect.sleep, Effect.timeout, Schedule, Effect.yieldNow |
ambient-reads | process.env, JSON.parse, Date.now(), new Date(), Math.random, randomUUID | Config, Schema.fromJsonString, Clock, DateTime, Random, Crypto |
module-state | top-level let and var | a Ref owned by the service's Layer |
error-classes | class … extends Error (and the other built-in errors) | Data.TaggedError, Schema.TaggedError |
console-output | console.*, node:console | Effect.log* with a Logger layer; the Console or output service |
immediate-transaction | a literal BEGIN … passed to exec(…) | inImmediateTransaction from storage/transaction |
event-journal-reads | a string or template literal naming domain_events or event_journal_meta | the events module: listAggregateEvents, listObservationRecords, readEventJournalSnapshot, the journal reader |
sql-row-assertion | as T on .prepare(…).get/all/iterate(…) | Schema.decodeUnknownSync at the row reader; a malformed row is storage_corrupt |
Floating promises are already an error in every package through the shared
profile's @typescript-eslint/no-floating-promises (strictTypeChecked); no
second rule repeats it.
Promise orchestration
Write a workflow as Effect.fn or Effect.gen and yield* its steps. Lift a
third-party Promise once, in its adapter, with Effect.tryPromise (or
Effect.promise when it cannot fail), and a callback API with
Effect.callback. Run a program once at the process entry, not in every
function. The Expo client has no single entry: its React hooks, event
handlers and vendor callbacks start programs only through the runtime bridge
in apps/client/src/platform/runtime, which returns a canceller for effect
cleanup and reports every exit. The CLI runs one program per process through
runProcessMain in apps/cli/src/bootstrap; its startup path loads Effect
Schema, Stream and Node's Stdio through local dynamic imports on first
use, because evaluating them costs every help and --version.
Restricted imports
Processes go through ChildProcess from effect/process with the
NodeChildProcessSpawner layer, files through FileSystem with the
NodeFileSystem layer, HTTP calls through HttpClient, and events through
PubSub, Queue or Stream. XState is being replaced by Effect: model a
machine as a service with Ref or SubscriptionRef state and Effect.fn
transitions. jsonc-parser left with the unwired Node configuration code (F7;
D-065). Type imports load nothing and pass,
including import { type X } (the repository does not set
verbatimModuleSyntax).
Timers and fetch
Effect.sleep, Effect.timeout and Schedule read the Clock, so
TestClock drives them in tests and interruption cancels them. Use
Effect.yieldNow or a Deferred instead of queueMicrotask.
Ambient reads
Configuration comes from Config with a ConfigProvider layer; tests provide
one instead of setting variables. JSON is decoded with
Schema.decodeUnknownEffect(Schema.fromJsonString(schema)). Time comes from
Clock.currentTimeMillis or DateTime.now, random numbers from Random, and
identifiers from the Crypto service or one IdGenerator service per
package (client-core and the client each define one as a context reference
whose default draws from Random, so tests seed or replace it).
Module state
A top-level let is shared by every runtime and test in the process. Keep the
state in a Ref (or SynchronizedRef) that the owning service's Layer
creates.
Typed errors
Declare each failure with Data.TaggedError or Schema.TaggedError and fail
through the Effect error channel, so callers match it by _tag.
Logging and output
Log with Effect.log, Effect.logWarning and Effect.logError under a
Logger layer. Write command output through the Console service or the
command's output service.
SQL row assertion
A SQLite row is untyped data. Decode it where it is read, with an Effect
Schema, and take the row type from typeof schema.Type. In a synchronous
store, decode with Schema.decodeUnknownOption and raise the typed
storage_corrupt runtime error from the failure branch, so a malformed row
fails closed instead of reaching a caller as a value of the wrong shape. Read
integers that may leave the safe range as TEXT (CAST(column AS TEXT)) and
decode them with Schema.NumberFromString, because the driver throws on an
integer it cannot hold. Reuse the protocol's schema for any bound or pairing
it already owns.
The rule reads syntax. It reports an assertion (as, as unknown as,
<T>, a non-null assertion) on .prepare(…).get/all/iterate(…) and on the
same call through a const bound once to a prepared statement, resolved
through its own scope. It misses a cast of a variable that already holds the
row, a statement passed in as a parameter or held in a property, computed
member access, and rows read through a helper; reviewers check those. Existing assertions sit in the
baseline and only shrink.
Immediate transactions
A write transaction begins in one helper, inImmediateTransaction in
services/node/src/storage/transaction/transaction.ts. It takes the write lock
with BEGIN IMMEDIATE, runs the body, commits, and on a failure rolls back
only while a transaction is still open, so the original error always reaches
the caller. SQLite can end a transaction itself (a full disk, an I/O error),
and a hand-written ROLLBACK after that fails and hides the real cause.
If the rollback itself fails, the helper closes the connection, so SQLite
rolls back and every later call fails instead of joining an abandoned
transaction; the original error still propagates, with the rollback failure
as its cause. Sample the clock inside the body, after the lock.
The rule reads syntax. It reports a string or template literal that starts
with BEGIN in the SQL argument of an exec(…) call, including one picked by a
conditional. It misses SQL built from parts and text held in a variable.
Existing hand-written transactions sit in the baseline and only
shrink. The journal's command writes stay there on purpose: one commits early
when a receipt already exists, and one nests as a SAVEPOINT.
Event journal
Only the events module (services/node/src/events) reads or writes the
journal tables domain_events and event_journal_meta. Every other module
gets what it needs through that module's functions (D-188, EV0d). The rule
reads syntax: it reports a string or template literal that names either table.
It misses a name built from parts or held in a variable. Migrations (schema
history), tests and test support are out of scope. The baseline for this rule
is empty; the allow-list in boundary adapters names the
files that may name the tables: the events module's append, list and read
files, and the observation-commit trigger that watches the journal for appends.
Boundary adapters
A thin adapter lifts a native, framework or vendor API into Effect. The
policy object lists
each adapter file with the exact rule/messageId capabilities it may use;
every other rule still applies to it.
- SDK Promise facade: the public SDK API and its
MaiaTransportreturn Promises and takeAbortSignal; its two public error classes extendErrorso public declarations never reacheffect. The facade runs each operation of the SDK's Effect-native core once (D-068), and one module reads the platformfetchthat the publicNodeApiTransportOptions.fetchoption defaults to. - Pi adapters: Pi's session factories, tool
executecallbacks and event handlers take or return Promises. The managed worker and the agent runner mix Pi calls with Maia-owned lifecycle and dispatch, so they are not listed. - Tauri, DOM and React bridges: Tauri
invoke,getUserMedia,React.lazy, a document-scoped stylesheet and a focus change after render. - SQLite transaction:
inImmediateTransactionwraps the synchronousBEGIN IMMEDIATE,COMMITandROLLBACKofnode:sqlitein one module. - Secure filesystem:
restricted-importscounts eachnode:fsname apart. The primitives Effect'sFileSystemlacks (a no-followopenand its raw descriptors,lstat, bigint descriptor identity throughfstat,opendir, the executableaccesscheck, thestatfsfree-space query,constants) report assecureFileSystem, which listed files may import; their plain operations (mkdir,readFile,rename) still count against them. - Synchronous procfs read: the suspend-aware clock reads
/proc/uptimewithreadFileSyncin one thin module. Effect'sFileSystemcompletes through the event loop, where a suspend can fall between the kernel's capture and the reading's use. - Node API HTTP server and native process host: the hardened loopback
servers keep
node:http; the process host spawns its helper and process trees. - Dashboard terminal (
apps/cli/src/dashboard): one scoped service owns the terminal, its signals and pi-tui's callbacks, and releases them in itsScope. It lifts pi-tui byEffect.tryPromiseand needs no allowance. It also wrapsprocess.stdout.writeuntil theScopecloses: pi-tui writes its terminal modes toprocess.stdoutitself, so the wrapper is the only way to record each mode it turns on and reset exactly those. - Expo platform adapters need no allowance today.
A file that mixes an adapter callback with ordinary logic is not listed; its
counts stay in the baseline until the adapter part moves into its own thin
module. Adding an entry needs review. pnpm lint:policy fails when a listed
file no longer uses an allowance, so the list only shrinks too.
Baseline
The effectBoundaries section of scripts/quality/lint-policy-baseline.json
records each (rule, path) with its occurrence count, exactly like the
source-organization baseline.
ESLint stays silent on a file while its count is at most the recorded one and
reports every occurrence in it once the count grows, so new code fails at
once. pnpm lint:policy prints the counts per rule and workspace and fails on
growth or stale entries. After a migration, run
node scripts/eslint/effect-boundaries/audit/audit.mjs --prune, which lowers
or removes entries and never adds one, and commit the smaller file.
Limits: the rules read syntax, not types. They do not see a Promise returned
by a helper, .then chains, Effect.runPromise islands, throw or
try/catch. A binding shadows a global name only inside the scopes that
declare it, and an alias of a global object (const clock = Date; clock.now()) passes ambient-reads. Reviewers still check those.
Completed phases
Rows moved verbatim from the roadmap R0b table once done.
| Phase | Deliverable | Acceptance |
|---|---|---|
| EF0 — Effect 4.0.0 / Done | Move effect and @effect/* from 4.0.0-beta.106 to 4.0.0 stable after the TypeScript 7 upgrade and SO6g land; the owner approved the release-age exception for Effect. | Every package builds and tests green; registry parity 0 findings; public export snapshots unchanged unless a documented Effect change requires it. |
| EF1 — Effect adoption audit / Done | Per-package inventory of non-Effect orchestration, timers, retries, validation, configuration, logging, HTTP, process and file access outside named boundary adapters. | Counts per package and an allow-list of boundary adapters, recorded beside the rules. |
Progress notes
Moved from the roadmap R0b section.
EF0, done 2 October 2026 (D-065). effect,
@effect/platform-node-shared and @effect/vitest are 4.0.0, with Vitest
5.0.1. Every package builds and tests green, registry parity has 0 findings
and the protocol public export snapshot is unchanged. The CLI standalone grows
from 259,700 to 279,060 bytes under a ceiling raised to 277 KiB, the Node
executable from 3,006,405 to 3,037,358 bytes, and the client web entry by
81,447 bytes (62,147 after the Lucide removal below).
R0b gates, 2 October 2026 (D-066). EF1 is
done and EF2's lint gate is in place: the maia-effect rules
(this document) run at error severity on every
workspace src/, with an allow-list of adapter files per capability. Their
baseline holds 1,422 occurrences in 303 entries: Node 920, CLI 230, agent 178,
client 44, client-core 27, SDK 21 and protocol 2 (1,005 Promise orchestration,
265 ambient reads, 110 restricted imports, 25 error classes, 9 raw globals, 8
module-level let, no console). The maia-test rules
(testing policy) hold 66 occurrences in 38 entries (59
owned double casts, 7 test sleeps, no owned module mocks): Node 30, CLI 19,
client-core 14, client 2 and agent 1. TQ0 coverage, lines and branches of the
files each suite loads: protocol 97.92/87.80%, client-core 93.71/87.90%,
client 92.38/88.04%, SDK 91.17/90.30%, Node 87.77/75.43%, agent
85.83/80.02%, CLI 84.85/86.29%, desktop scripts 66.50/88.24% and site
100/100% (pnpm coverage). TQ0 still names no flaky tests. The startup budget
measures again (native protocol 3 accepted; Node bundle ceiling 3,264 KiB),
and a managed Pi worker cold start takes 222-248 ms on a laptop
(agent.worker.cold_start.ms).
DEP1, first wave (2 October 2026). Removed typebox (agent),
@tailwindcss/typography, @mdx-js/react, postcss and autoprefixer
(site) and lucide-react-native (client), together with Effect 4.0.0's
runtime dependencies and the Pi 0.87.1 update. Direct external dependencies go
from 60 to 55, lockfile package versions from 1,478 to 1,472 (package names
from 1,349 to 1,308; Pi 0.87.1 pins its own esbuild 0.28.2 beside the
repository's 0.28.1), and a clean install from 1,627 to 1,586 MiB. Next:
jsonc-parser and the unwired Node configuration code, the React Native
peer-only packages after a native build check, yaml and xstate.
EF2 and TQ1 for client-core and the client (2 October 2026). Both
packages reach 0 in the maia-effect and maia-test baselines (client-core
27 and 14, the client 44 and 2): one IdGenerator per package on Effect
Random replaces the module-level ID counters, Clock replaces the clock
read, Data.TaggedError replaces nine error classes, the client runs every
program through one runtime bridge in src/platform/runtime, and an Effect
transition table with a Ref replaces the client's XState terminal
lifecycle, so the client no longer depends on xstate. Tests build the real
SDK client over a scripted transport and use TestClock instead of sleeps.
Coverage: client-core 94.56/89.03% (from 93.71/87.90%); the client
90.14/88.21% (from 92.38/88.04%), lower in lines only because its tests now
load the terminal controller, which no test loaded before. The web entry
grows by 7,203 bytes (0.21%). The protocol's two process.env
reads in local-node/profile-location.ts remain: removing them without a
behavior change needs the SDK and Node callers to pass those values.
EF2 and TQ1 for the SDK and the CLI (2 October 2026,
D-068). Both packages reach 0 in the
maia-effect and maia-test baselines (the SDK 21 and 0, the CLI 230 and
19). Every SDK operation is an Effect value behind the unchanged Promise API,
and @maiaos/sdk/node/effect is the one public Effect entry. Every CLI command
runs as an Effect program on one runtime at the process entry, with Config,
Crypto, Stdio, FileSystem and ChildProcess services; its tests build
the real Effect SDK client over a scripted transport instead of 19 owned
casts. Coverage: SDK 90.49/91.05% (from 91.17/90.30%), CLI 88.55/86.86%
(from 88.17/86.25%). The CLI standalone grows from 279,060 to 291,324 bytes
under a ceiling raised to 288 KiB, and the cold start of help and
--version from 73 to 84 ms (pnpm metrics:compare, load average 29).
DEP1, Pi 1.0.0 (2 October 2026, D-067). Pi
moves from 0.87.1 to 1.0.0 under a name-level release-age exclusion for
official @earendil-works/pi-* packages and one exact
@earendil-works/chord@1.0.0 entry, removed after 8 October 2026, 19:13 UTC.
Pi's built-in codemode, llama.cpp, MCP and tool-search extensions are proven
off on every Maia path, and two older ambient reads of the user's Pi profile
and project prompt files are closed. Lockfile package versions go from 1,480
to 1,473 and a clean install grows by 117,855 bytes (0.01%). The agent npm
pack grows by 116,916 bytes (4.25%; the managed worker bundles Pi), the Node
executable by 91 bytes, and a cold managed worker start is unchanged (261 ms
before, 264 ms after, p = 0.67).
Imported from Maia OS docs at a2175bb