Aller au contenu

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.

RuleReportsWrite instead
promise-orchestrationnew Promise, Promise.all/race/allSettled/any/withResolvers/try, async functions, awaitEffect.fn, Effect.gen, Effect.all, Effect.race, Effect.callback
restricted-importsnode:child_process, node:fs, fs/promises, node:http(s), node:events, xstate, jsonc-parserChildProcess (effect/process), FileSystem, HttpClient and HttpServer (effect/http), PubSub/Queue/Stream
raw-globalsfetch, setTimeout, setInterval, queueMicrotaskHttpClient, Effect.sleep, Effect.timeout, Schedule, Effect.yieldNow
ambient-readsprocess.env, JSON.parse, Date.now(), new Date(), Math.random, randomUUIDConfig, Schema.fromJsonString, Clock, DateTime, Random, Crypto
module-statetop-level let and vara Ref owned by the service's Layer
error-classesclass … extends Error (and the other built-in errors)Data.TaggedError, Schema.TaggedError
console-outputconsole.*, node:consoleEffect.log* with a Logger layer; the Console or output service
immediate-transactiona literal BEGIN … passed to exec(…)inImmediateTransaction from storage/transaction
event-journal-readsa string or template literal naming domain_events or event_journal_metathe events module: listAggregateEvents, listObservationRecords, readEventJournalSnapshot, the journal reader
sql-row-assertionas 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 MaiaTransport return Promises and take AbortSignal; its two public error classes extend Error so public declarations never reach effect. The facade runs each operation of the SDK's Effect-native core once (D-068), and one module reads the platform fetch that the public NodeApiTransportOptions.fetch option defaults to.
  • Pi adapters: Pi's session factories, tool execute callbacks 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: inImmediateTransaction wraps the synchronous BEGIN IMMEDIATE, COMMIT and ROLLBACK of node:sqlite in one module.
  • Secure filesystem: restricted-imports counts each node:fs name apart. The primitives Effect's FileSystem lacks (a no-follow open and its raw descriptors, lstat, bigint descriptor identity through fstat, opendir, the executable access check, the statfs free-space query, constants) report as secureFileSystem, which listed files may import; their plain operations (mkdir, readFile, rename) still count against them.
  • Synchronous procfs read: the suspend-aware clock reads /proc/uptime with readFileSync in one thin module. Effect's FileSystem completes 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 its Scope. It lifts pi-tui by Effect.tryPromise and needs no allowance. It also wraps process.stdout.write until the Scope closes: pi-tui writes its terminal modes to process.stdout itself, 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.

PhaseDeliverableAcceptance
EF0 — Effect 4.0.0 / DoneMove 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 / DonePer-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