How to Make Contributions

September 24, 2026 · View on GitHub

Thank you for considering contributing to this project! We welcome all contributions, whether it's bug reports, feature requests, documentation improvements, or code contributions.

Table of Contents

Contributing

Getting Started

Prerequisites

Before you begin, make sure you have the following installed:

  • Java 25 (or later): the build refuses to load on an older JDK
  • Scala
  • Node (Node 24+ to run the experimental WASM target: it defaults to V8's Turboshaft Wasm pipeline)
  • sbt (Scala Build Tool)
  • Git

Setting Up Your Environment

  1. Fork the Repository

    • Navigate to the kyo repository and click the Fork button.
  2. Clone Your Fork

    git clone https://github.com/your-username/kyo.git
    cd kyo
    
  3. Set Up Upstream Remote

    git remote add upstream https://github.com/getkyo/kyo.git
    

Configuring Java Options

The sbt JVM is configured by the checked-in .jvmopts, so no environment setup is needed. The sbt launcher appends .jvmopts after JAVA_OPTS, so a flag set in both takes the .jvmopts value, and an -Xmx in JAVA_OPTS has no effect.

Explanation of Parameters

  • -Xmx12G: maximum heap of 12GB.
  • -Xss10M: thread stack size of 10MB.
  • -XX:+UseG1GC: the G1 garbage collector.
  • -XX:+UseCompactObjectHeaders: compact object headers (a JDK 25 flag).
  • -XX:MaxMetaspaceSize=2G: metaspace capped at 2GB.
  • -XX:ReservedCodeCacheSize=256M: 256MB reserved for compiled code.
  • -Dfile.encoding=UTF-8: UTF-8 file encoding.

Adjusting These Values

To change the heap for one run, pass it on the command line, which the launcher places after .jvmopts:

sbt -J-Xmx8G 'kyo-coreJVM/test'

JAVA_OPTS still carries flags that .jvmopts does not set, such as -Xms.

How to Build Locally

Windows toolchains

Builds that reach a kyo-ffi module need a C compiler. The FFI plugin reads the CC environment variable and otherwise invokes cc. If GCC is installed on Windows as gcc.exe but no cc.exe alias exists, set CC before starting sbt:

$env:CC = "gcc"

Some modules also use MSVC. Install the Visual Studio Build Tools C++ workload, then run the build from a shell initialized by VsDevCmd.bat so cl.exe, link.exe, INCLUDE, and LIB are available. For an x86_64 build:

cmd.exe /k '"C:\Program Files\Microsoft Visual Studio\2022\BuildTools\Common7\Tools\VsDevCmd.bat" -arch=amd64'

This opens an initialized Command Prompt. Run powershell inside it if the commands below are being entered in PowerShell.

Confirm the initialized shell sees both tools with where.exe cl and where.exe link. See kyo-aeron's contributor guide for that module's required Windows staging step.

On Windows ARM64, install the native ARM64 Podman package explicitly. Scoop can otherwise select a package for a different architecture:

scoop install -a arm64 podman
podman machine init
podman machine start
podman info

Run podman machine init only when creating the machine. Start an existing machine with podman machine start.

Local build runner

On native Windows, invoke POSIX build scripts through Git Bash. Use the direct environment for diff-selected verification:

& $env:SHELL -lc 'scripts/build.sh --env direct testDiff JVM JS'

Do not use --env podman testDiff. The container receives a clean source archive without .git, so the diff selector cannot discover the changed modules. Use --env podman test when a full Linux container run is needed:

& $env:SHELL -lc 'scripts/build.sh --env podman test JVM JS Native'

Run the following commands to build and test the project locally:

sbt '+kyoJVM/test' # Runs JVM tests
sbt '+kyoJS/test'  # Runs JS tests
sbt '+kyoNative/Test/compile' # Compiles Native code

Format before submitting. A bare scalafmtAll or scalafmtCheckAll reaches only the JVM projects, so name all four platform aggregates, as CI does:

sbt kyoJVM/scalafmtAll kyoJS/scalafmtAll kyoNative/scalafmtAll kyoWasm/scalafmtAll scalafmtSbt

CI runs this command and fails if it leaves a diff.

Running CI in Your Fork

Pull requests to the main repository require maintainer approval before CI runs, so checks may not start right away. To get full CI signal on your own schedule, run the same workflows in your fork. They use only GitHub-hosted runners that are free for public repositories and read no repository secrets, so they run unmodified.

  1. Enable Actions on your fork. GitHub disables a fork's workflows by default. Open your fork's Actions tab (https://github.com/<your-user>/<your-fork>/actions) and enable them when prompted.

  2. Run the ci-dispatch workflow. The ci workflow runs only on pushes and pull requests to main; the manually runnable one is ci-dispatch (.github/workflows/ci-dispatch.yml). In the Actions tab, select ci-dispatch and click Run workflow, then choose your branch. Pull-request CI runs four os poles: linux-x64 and linux-arm64 with JVM, JS, Native and Wasm, and windows-x64 and windows-arm64 with JVM and JS. The oses default omits windows-arm64; set it to linux-x64 linux-arm64 windows-x64 windows-arm64 to match pull-request CI. Leave mode as full for a complete run, or set it to diff to test only the modules your branch changed, which is what pull-request CI runs.

  3. Or open a fork-internal pull request. A PR from your working branch against your fork's own main triggers the same diff-mode run an upstream PR would, on your runners, with no approval needed. It also runs release-probe, a no-secrets publishability check, for free.

Diff mode compares against your fork's main, so sync your fork before a diff-mode run (the Sync fork button, or git fetch upstream && git push origin main) to match upstream. The first run is slower while the runner caches warm up.

When you open your pull request, include a link to the fork CI run if you have one, so reviewers can see the result.

Adding a New API

If you want to contribute a new method or type, feel free to:

  • Open an issue
  • Discuss on Discord: https://discord.gg/afch62vKaW
  • Share design examples:
    • Use cases
    • Equivalent in ZIO or Cats Effect
    • Other motivating patterns

Where to Add Your API

SubprojectUse For
kyo-dataData structures (Chunk, Maybe, Result, etc.)
kyo-preludeEffect types without Sync (Abort, Env, Var, etc.)
kyo-coreMethods requiring Sync
kyo-systemFile system, OS process, and environment methods
kyo-combinatorsExtensions or composition helpers

Add corresponding tests in the same subproject.

Example:
A new Stream.fromSomething method:

  • If it uses Sync: place it in kyo-core/shared/src/main/scala/kyo/StreamCoreExtensions.scala
  • If it doesn't: place it in kyo-prelude/shared/src/main/scala/kyo/Stream.scala

LLM Use Guide

We encourage contributors to leverage Large Language Models (LLMs) responsibly:

  • Do not submit low-effort, AI-generated code without review.
  • If you use AI assistance, ensure that the submission is well-tested and meets our standards.
  • Automated PRs without human oversight may be closed.

Core Principles

These are the axioms. Everything else in this guide derives from them.

  1. Source files are documentation. Every .scala file is meant to be read top-to-bottom. Method ordering, scaladocs, section separators, and the flow from public API down to internals all serve readability. A contributor opening a file for the first time should understand the type's purpose and usage patterns by reading the file, before touching any external docs. Treat the ordering and structure of a file as carefully as you treat the implementation.

  2. Most-used first. Within each section, prioritize what users reach for most. Factory methods before configuration, run before runWith, simple overloads before complex ones. Discoverability beats alphabetical order.

  3. Action verbs, not theory. foreach not traverse. Accessible naming lowers the barrier to entry and keeps the API approachable for developers who don't have category theory backgrounds.

  4. Performance is a first-class feature. Avoid allocations, avoid unnecessary suspensions, use inline and opaque types where appropriate. Zero-cost abstractions aren't optional: they're the reason Kyo can be both safe and fast.

  5. Composition over inheritance. Delegate, don't extend. No protected, no deep hierarchies. Build complex behavior by combining simple pieces.

  6. Type safety first, escape hatches as last resort. Write type-safe code by default. asInstanceOf and @unchecked are acceptable only when they're strictly necessary inside opaque type boundaries or kernel internals where the type system can't express a known invariant, never as a convenience shortcut. Never use @uncheckedVariance. Frame, Tag, and AllowUnsafe guard the public surface where users interact.

  7. Symmetry across related types. Paired or complementary types should share the same structural patterns (factory methods, config, lifecycle) and naming conventions. Keep names consistent: if one type uses close, the paired type should too. When users learn one side, the other should feel familiar.

  8. Explain the surprising, skip the obvious. A comment on a race condition is essential. A comment on get returning a value is noise.


API Design

Naming

Don't writeWrite insteadWhy
traverseforeachDescribes the action, not the structure
sequencecollectAllSays what it does
pure / succeedKyo.lift or rely on implicit liftingNo ceremony for the common case
void / as(()).unitDirect
*> / >>.andThenReadable without memorizing operators
replicateMfillPlain English
filterAfilterSame name as stdlib
foldMfoldLeftSame name as stdlib
bracketacquireReleaseSays what it does
provide / provideLayerEnv.run / Env.runLayerConsistent run pattern

No symbolic operators in kyo-data, kyo-prelude, or kyo-core. Use named methods (.andThen, .unit, .map). Symbolic operators like *>, <*>, <&> live exclusively in kyo-combinators for users who prefer that style.

Effect operations follow consistent naming:

  • run eliminates an effect: Abort.run, Var.run, Emit.run, Choice.run, Env.run. Common handler variants beyond run:

    • runWith(v)(continue): canonical handler with continuation
    • runTuple: returns (State, A) tuple
    • runDiscard: discards emitted/intermediate values
    • runPartial / runPartialOrThrow: handles a subset of a union error type Non-run eliminators have specific semantics beyond elimination:
    • recover / recoverError: recovers from errors with a fallback value
    • catching: catches exceptions and converts to effect errors
    • fold / foldError: maps all result cases (success/failure) to a single return type
  • get extracts a value from a container type, lifting the error case into the effect. Abort.get(either) extracts the Right value, lifting Left into Abort. Abort.get(maybe) extracts Present, lifting Absent into Abort. For context effects, get demands the current value: Env.get[R]: R < Env[R], Var.get[V]: V < Var[V].

  • use applies a function to the gotten value: Env.use[R](f: R => A < S), Var.use[V](f: V => A < S).

  • init / initWith / use / initUnscoped / initUnscopedWith: resource factory variants with increasing lifecycle control:

    • init: creates a Scope-managed resource (default choice)
    • initWith(f): creates a Scope-managed resource and applies f to it
    • use(f): bracket semantics without Scope in the effect set
    • initUnscoped: no cleanup guarantees, caller manages lifecycle
    • initUnscopedWith(f): no cleanup, applies f

    See Closeable Resource Pattern for the full delegation chain.

  • fooPure suffix for pure (non-effectful) variants: mapPure, filterPure, collectPure, contramapPure. The pure version avoids suspension overhead. Used consistently across Stream, Pipe, and Sink.

  • fooDiscard drops the return value: offer returns Boolean, offerDiscard returns Unit. Same for complete/completeDiscard, interrupt/interruptDiscard, etc.

  • Sync-try vs async-wait: sync-try operations use names that imply attempt (offer, poll) and return a success indicator (Boolean, Maybe). Async-wait operations use names that imply completion (put, take) and suspend until done. The async version tries the sync version first and only suspends on failure.

  • noop / Noop for degenerate cases (formally, the identity implementation): Latch.init(0) returns a pre-completed noop, Meter.Noop is a no-op meter that passes through without rate-limiting. This is both an optimization (avoids allocating real state when nothing will happen) and a naming convention for when you need an identity/pass-through implementation of a type.

Types

When a Kyo primitive exists for a concept, use it instead of the stdlib equivalent.

kyo-data, foundational value types:

Kyo primitiveReplacesNotes
Maybe[A]Option[A]Option only as conversion input (e.g., Abort.get(opt: Option[A])). When stdlib methods return Option (e.g., collectFirst), convert to Maybe as soon as possible via Maybe.fromOption
Result[E, A]Either, TryThree-way: Success/Failure/Panic; never raw Either or Try in effect signatures
Chunk[A]Seq, List, VectorUse internally; accept generic collections in public APIs (see below)
Durationjava.time.Duration, scala.concurrent.duration.DurationOpaque Long-based, zero-allocation
ByteSizea bare Long or Int holding a byte countOpaque Long-based, zero-allocation, saturating arithmetic, non-negative by construction
Instantjava.time.InstantKyo's own wrapper with consistent API
Span[A]IArray[A], ArraySeq[A]Immutable array wrapper, avoids boxing, O(1) indexing
ScheduleCustom retry/timing logicComposable scheduling policies
TypeMap[A]Heterogeneous mapsType-safe map keyed by type

Prefer ByteSize over a bare numeric type wherever a value means "a quantity of bytes": storage and disk sizes, file sizes, buffer and byte-array capacities, read and write chunk sizes, network packet and frame sizes, transfer limits and quotas, memory footprints. It carries the unit in the type, so a call site cannot silently pass kibibytes where bytes were expected, and its arithmetic saturates instead of overflowing. A raw Long or Int stays correct for an index, an offset into a buffer, or a count of elements; those are positions, not sizes.

This is guidance for new and changed code. Existing APIs that thread byte counts as Long are not required to migrate, and a migration should be its own change rather than a drive-by edit inside an unrelated one.

kyo-prelude, effects:

Kyo primitivePurposeNotes
Abort[E]Short-circuit errorsTyped error channel; use Abort.fail, Abort.recover, Abort.run
Env[R]Dependency injectionContext effect; use Env.get, Env.run
Var[V]Mutable stateEffect-tracked state; use Var.get, Var.set, Var.run
Emit[V]Value emissionPush-based output; use Emit.value, Emit.run
Poll[V]Value pollingPull-based input; use Poll.one, Poll.run
ChoiceNondeterminismMultiple values; use Choice.eval, Choice.run
CheckAssertion checkingLightweight validation; use Check.require, Check.runAbort
BatchAutomatic batchingGroups operations for batch execution
MemoMemoizationCaches results; opaque alias for Var[Memo.Cache]

kyo-prelude, streaming and composition:

Kyo primitivePurposeNotes
Stream[V, S]Lazy effectful sequencesChunked push/pull hybrid; prefer over manual Emit for sequences
Pipe[A, B, S]Stream transformationComposable Stream[A] => Stream[B]
Sink[V, A, S]Stream consumptionComposable Stream[V] => A
Layer[Out, S]Dependency injectionComposable; use Layer.init for compile-time wiring

kyo-core, effects:

Kyo primitivePurposeNotes
SyncEffect suspensionMarks side-effecting code; use Sync.defer { ... }
AsyncConcurrencyMain effect for concurrent programming; prefer over direct Fiber use
ScopeResource managementacquireRelease, ensure; structured cleanup via Scope.run
ClockTime operationsnow, sleep, deadline; withTimeControl for testing
LogLoggingtrace, debug, info, warn, error with level control
ConsoleConsole I/OreadLine, print, printLine, printErr
RandomRandom generationSeeded or context-bound; use for testability
RetryRetry with policyTakes a Schedule from kyo-data

kyo-core, concurrency primitives:

Kyo primitivePurposeNotes
Fiber[A]Async computation handleLow-level; prefer Async API in application code
Channel[A]Async message passingBounded, backpressured; use over raw queues for async communication
Queue[A]Concurrent collectionSynchronous ops, bounded/unbounded; use Channel when async backpressure is needed
Hub[A]Broadcast messagingFan-out to multiple listeners; built on Channel + Fiber
Signal[A]Reactive valueCurrent + change notification; use for observable state
AtomicInt, AtomicLong, AtomicBoolean, AtomicRef[A]Atomic operationsThread-safe single-value containers
LongAdderHigh-contention counterUse over AtomicLong for write-heavy workloads
MeterConcurrency controlSemaphore, mutex, rate limiter; composable via pipeline
LatchOne-shot barrierUse for coordination points

Error types (Closed is in kyo-kernel, the rest in kyo-core):

Kyo primitivePurposeNotes
ClosedResource closedStandard error for closeable resources; carries creation Frame
TimeoutOperation timed outUsed by Async.timeout and related APIs
InterruptedFiber interruptedA KyoException; an interrupted fiber completes with it as a Panic
RejectedAdmission rejectedLoad shedding signal from Admission

kyo-system (file system, OS processes, and environment):

Kyo primitivePurposeNotes
PathFile system operationsImmutable, cross-platform; construct with /, reads carry PathRead, writes PathWrite
FileSystemPluggable backendHost filesystem service the runners install
CommandOS process launchBuilds and runs external processes; spawn, text, waitFor
ProcessRunning process handlestdout, stderr, waitFor, destroy
SystemEnvironment/propertiesType-safe access with custom Parsers
FileSystemExceptionFile I/O errorsSealed hierarchy: FileReadException, FileWriteException, FileStructureException

Accept generic collections in public APIs, use Chunk internally:

def foreach[CC[+X] <: Iterable[X] & IterableOps[X, CC, CC[X]], A, B, S](
    source: CC[A]
)(f: A => B < S)(using Frame): CC[B] < S =
    Kyo.foreach(Chunk.from(source))(f).map(source.iterableFactory.from(_))

Failure Tracking

A typed Abort[E] row is a precise contract: it names exactly which failures an operation can produce, so a caller handles one and the compiler narrows the row to prove what remains. A blanket error-base on a row, or a raw non-module exception leaking onto it, discards that; the row degrades to "something might fail" and the leaf hierarchy becomes decorative.

Each public operation's row names its precise failure set. Declare the narrowest type that covers exactly what the operation produces; do not smear a module-wide error base onto a row when the operation raises a small subset of it.

One sealed module-exception hierarchy, a sealed trait per operation, leaves that mix in. This is how kyo-jsonrpc's JsonRpcError and kyo-http's HttpException are organized:

sealed abstract class FooException(code: Int, message: String, ...)(using Frame) extends KyoException(message, ...)

sealed trait FooCallFailure extends FooException   // one trait per operation; this is the row type
sealed trait FooReadFailure extends FooException

case class FooUnknownException(name: String)(using Frame)
    extends FooException(...) with FooCallFailure                      // leaf in one operation
case class FooConnectionClosedException()(using Frame)
    extends FooException(...) with FooCallFailure with FooReadFailure  // shared leaf, several operations

A trait can extend the parameter-carrying base because the concrete leaf supplies the constructor arguments, so a leaf mixes in every operation-trait it belongs to. The operation's row is then the operation-trait (def call(...): A < Abort[FooCallFailure]): one lean type in the signature that is exactly that operation's sealed failure set, matched exhaustively.

No raw non-module exception on a public row. A kyo Closed / Timeout, a wire-protocol error, or a raw Throwable reaching a public row is untyped tracking-loss: it carries no module-level message and cannot be matched as part of the model. Map it to a typed leaf at the boundary (the Abort.recover site); a closed transport becomes a typed *ConnectionClosedException leaf, a remote wire error becomes a typed remote-error leaf.

Handler-body rows carry the user's error, not a framework blanket. When a module accepts a user-supplied handler, the body's row is Abort[E | <control signal>] where E is the user's declared or inferred error; never a fixed module-exception blanket. A body that raises no module failure has an empty E; one that calls a framework accessor infers that accessor's leaves into E.

Every leaf carries its own message, built from typed fields. The leaf's fields are typed (name: String, uri: Uri, requested: Version) and its message is constructed from them in the case-class body; no free-form detail: String parameter stands in for structure. See KyoException Convention for the base-class mechanics.

Construction-time validation panics; it does not appear on a tracked Abort row. A bad configuration (an empty required set, a non-positive timeout, a handler claiming a framework-reserved code) is a construction-time programmer error, so require throws rather than threading an Abort[ConfigError] through every init signature, exactly as the sibling JsonRpcHandler.Config.require does. Prefer a typed leaf for the thrown payload (McpConfigurationError) over a raw IllegalArgumentException so the message stays structured, but that leaf carries no operation-trait and never reaches a row. Impossible states and misuse (an accessor used outside its dynamic extent, a partition invariant) are likewise bugs, not tracked failures: they panic and stay off every row.

Method Signatures

(using Frame) as Type Parameter Separator

Scala 3 doesn't support def foo[A][B, C]. The workaround is to use (using Frame) between type parameter clauses. The first clause holds the type parameter the user specifies explicitly; the second holds types inferred from value arguments:

//                      user specifies E     inferred from args
//                      vvvvvvvvvvvvvvvv     vvvvvvvvvvvvvvvvvvvv
inline def get[E](using inline frame: Frame)[A](either: Either[E, A]): A < Abort[E]

inline def runWith[E](using Frame)[A, S, ER, B, S2](
    v: => A < (Abort[E | ER] & S)
)(continue: Result[E, A] => B < S2)(...): B < (S & reduce.SReduced & S2)

def apply[E: ConcreteTag](using Frame)[A, S](
    v: => A < (Abort[E] & S)
): A < (Async & Abort[E] & S)

Pattern: [UserSpecified](using Frame)[InferredFromArgs]

using Clause Ordering

Inline methods put Tag before Frame:

inline def get[V](using inline tag: Tag[Var[V]], inline frame: Frame): V < Var[V]

Non-inline methods put Frame before type-level evidence:

def run[E](
    using Frame
)[A, S, ER](v: => A < (Abort[E | ER] & S))(
    using
    ct: ConcreteTag[E],
    reduce: Reducible[Abort[ER]]
): Result[E, A] < (S & reduce.SReduced)

AllowUnsafe always last:

def init(parallelism: Int)(spawn: Unit < Async => Unit)(using frame: Frame, u: AllowUnsafe): Finalizer

Frame and Tag

  • Frame on every method that suspends or handles effects. Never on pure data accessors (capacity, size). Always inline on inline methods for zero-cost source location capture.
  • Tag when runtime effect dispatch is needed. Parametric effects like Var[V], Emit[V], Env[R] require tags because the handler must identify which effect to match at runtime.

@targetName for Extension Methods

Use @targetName to disambiguate extension methods on opaque types that erase to the same JVM signature as methods on the underlying type.

Overload Organization

Simple variants delegate to the canonical implementation and never duplicate logic:

// Canonical: does the actual work
private[kyo] inline def runWith[V, A, S, B, S2](state: V)(v: A < (Var[V] & S))(
    inline f: (V, A) => B < S2
): B < (S & S2) = ArrowEffect.handleLoopState(...)

// Variants: project the result differently
def run[V, A, S](state: V)(v: A < (Var[V] & S)): A < S =
    runWith(state)(v)((_, result) => result)

def runTuple[V, A, S](state: V)(v: A < (Var[V] & S)): (V, A) < S =
    runWith(state)(v)((state, result) => (state, result))

For overloads by arity: variadic delegates to Seq, Seq delegates to Seq+config:

def race(first, rest*) = race(first +: rest)
def gather(iterable) = gather(iterable.size)(iterable)

Ordered by increasing arity/complexity within each group.


Code Conventions

Pending Type (A < S)

A < S represents a computation that produces an A with pending effects S. Kyo automatically lifts plain values into computations: any A can be used where A < S is expected, with no wrapping needed. Use Kyo.lift only when the compiler struggles with type inference.

  • Avoid nested computations ((A < S1) < S2). They require .flatten and make code harder to follow. Use .map chains or for-comprehensions to keep computations flat. Kyo.lift exists to wrap a value as a nested computation when unavoidable, but needing it usually signals the code should be restructured.
  • Use .map, not .flatMap. They are identical on pending types; flatMap exists for for-comprehensions only
  • Use .andThen(next) to sequence, not .map(_ => next)
  • Use .unit to discard a result to Unit < S
  • Prefer .map chains over for-comprehensions; use for-comprehensions when readability benefits (many dependent steps)
  • Use .handle(Abort.run, Env.run(x)) for left-to-right handler pipelines instead of nested calls
  • Prefer Abort.recover over Abort.run + Result pattern matching

Scala Conventions

  • Prefer functional style: no mutable vars, no while loops, no throw/catch for control flow. Use val, recursion (with @tailrec), Loop, and Abort for errors. Mutable state is acceptable only in performance-critical internals (atomics, bit-packing) where it's encapsulated behind a pure interface.
  • Use discard(expr) to suppress unused value warnings, not val _ = expr
  • Provide CanEqual for all comparable types: use derives CanEqual on case classes and enums. Skip types with non-comparable fields like functions.
  • Minimize explicit type parameters at call sites. APIs should infer well from value arguments. If a user must write Abort.fail[String]("error") instead of Abort.fail("error"), that's a design smell. Use the (using Frame) type parameter separator pattern (see Method Signatures) when user-specified types are unavoidable
  • Explicit return types on public API only; let the compiler infer elsewhere:
    def offer(v: A)(using Frame): Boolean < (Sync & Abort[Closed])   // public: explicit
    private def helper(v: A) = ...                                    // private: inferred
    val result = someCall()                                           // val: inferred
    val chunk = Chunk.from(values)                                    // not Chunk.from[A](values)
    
  • No protected; use private[kyo] or private[kernel]
  • All public APIs in the kyo package; internal code uses kyo.internal
  • Avoid asInstanceOf and @unchecked. They are acceptable only inside opaque type boundaries or kernel internals. Never use @uncheckedVariance
  • Imports: specific over wildcard, internal wildcards OK, grouped by origin
  • Keep S open if appropriate: use A < (S & SomeEffect) instead of A < SomeEffect
  • Prefer call-by-name (body: => A < S) for methods that capture a side-effecting body. This is a safety net in case the user forgets to suspend side effects: without call-by-name, Abort.catching(connection.read()) would execute read() eagerly before catching can intercept the exception
  • Mark methods final in abstract classes/traits when not intended for override
  • Format all four platforms before submitting (see How to Build Locally)

Documentation

Type-Level Scaladoc

Every main public type needs a scaladoc (8-35 lines) covering:

  • Opening sentence: what the type is, brief and definitional
  • Conceptual "why": 1-3 paragraphs on mental model and design rationale
  • Behavior a caller must know that the signatures do not carry. Name key operations only as navigation (which entry point to reach for), never a description of what each method does; that belongs on the method (see Method-Level Scaladoc)
  • Gotcha callouts: WARNING:, IMPORTANT:, or Note: for surprising behavior
  • @tparam tags for all type parameters
  • @see references: 3-6 links per type, grouped by topic (creation, handling, related types)
  • No code examples unless demonstrating composition patterns or system property syntax (rare)

WARNING/IMPORTANT/Note decision:

  • WARNING:: risk of data loss, memory exhaustion, or incorrect behavior if misused
  • IMPORTANT:: subtle semantic distinction that affects correctness
  • Note:: behavioral clarification or platform difference

Method-Level Scaladoc

  • Brief description (1-3 lines)
  • @param / @return only when name and type aren't enough
  • Skip for truly trivially obvious methods (capacity: Int, size: Int)

Markdown Formatting

Scaladoc parses comments as CommonMark markdown (the Scala 3 default). Scala 2 wiki syntax (=Heading=, '''bold''', ''italic'', [[url text]]) renders as literal text and must not be used.

Heading levels map to CSS classes that determine visual size:

SourceHTMLSize
# / ##h1 / h2very large (page-level)
###h3large
####h4moderate; use for in-class sub-sections
##### / ######h5 / h6small

Use #### for sub-sections inside a class/object/trait docstring. # and ## compete visually with the type's own title. For light visual grouping where no anchor or sidebar entry is wanted, use **bold** inline labels instead of a heading.

Code blocks: prefer markdown fenced blocks with a language tag for syntax highlighting:

```scala
val x: Int < Async = 42
```

Scala 2 {{{ }}} blocks render correctly (backward-compat) but get no syntax highlight; prefer fenced form for new code. Indented (4-space) code blocks do not work: they render as plain paragraphs.

Links:

  • External URLs: [text](https://…) markdown form. Never [[https://… text]] (Scala 2 wiki).
  • Internal type / member references: [[kyo.Foo]] or [[Foo]] (wiki-style member-link, kept for backward-compat and preferred for cross-references).
  • [[Foo custom label]] for a renamed link target.

Emphasis: **bold** and *italic*. Never '''bold''' / ''italic''.

Other supported markdown: GFM tables (| col |), block quotes (>), horizontal rules (--- / ***), ordered/unordered lists with nesting, strikethrough (~~), inline HTML (<em>, <sub>, etc.).

Scaladoc tags (@param, @tparam, @return, @throws, @note, @see, @example, @since) render in a structured "Attributes" section below the description. For @throws, always use a resolvable type name (e.g. @throws java.lang.IllegalArgumentException); bare type-parameter names like @throws E emit unresolvable-link warnings.

scalafmt interaction (critical): the repo .scalafmt.conf sets docstrings.wrap = keep so scalafmt preserves docstring line structure. Even with that setting, markdown still requires blank * lines between blocks: heading + paragraph, paragraph + list, paragraph + code block all need a blank separator line. Otherwise markdown joins them into a single paragraph.

Example of correct structure:

/** Single-sentence summary on the line right after the slash-star-star.
  *
  * Longer paragraph describing the type. Spans as many sentences as needed.
  *
  * #### Sub-section heading
  *
  * Paragraph that follows the heading. The blank `*` lines above and below
  * the heading are mandatory.
  *
  *   - List items go here
  *   - With a blank line above and below the list
  *
  * ```scala
  * val example = …
  * ```
  *
  * @tparam A summary
  * @param input summary
  */

Inline Comments

The default is no comment. Code that needs no explanation gets none. The list below is a closed set of exceptions, not an invitation to explain. A comment is a liability: the code moves and the comment does not.

A comment is warranted only as an answer to one of these:

  1. Non-obvious choices: why this shape and not the obvious one.
  2. Load-bearing invariants: what breaks if this changes, the ordering that must hold, why something stays that a reader would otherwise delete or simplify.
  3. External facts the types cannot carry: a JVM inlining budget, a runtime flag, a protocol requirement.
  4. Measured results, stated as the number.
  5. Race conditions and concurrency hazards: the interleaving, which carrier owns what.
  6. Bit-packing and encoding schemes: diagram the layout.
  7. Phase markers in multi-step methods: one line per phase naming the phase (// extract path params, // map errors), never restating a call. // process completed transfers above processCompletedTransfers() is noise.
  8. Navigational signposts in large methods (30+ lines) with non-linear control flow, so a reader can skim the structure.
  9. Required markers: // Unsafe: at a bridging site, // deviation: <reason> at a test that keeps an unavoidable real-clock bound (see Deterministic Tests), and the audit comments individual modules require at a declared exception.

Placement. Categories 7 and 8 live only inside a method body. Never put a navigational or phase comment on a top-level declaration, a build setting, a field, or an import block: those have no structure to navigate.

Three kill tests, applied to every comment before it ships:

  • Grep test: is the content recoverable by grepping the identifier below it? Then it is a tautology. Delete it.
  • Sync test: does it name anything nothing keeps in sync (test classes, call sites, file lists, counts)? It becomes false on the next rename and nothing will catch it. State the constraint, never the inventory.
  • Decision test: would removing it change what a maintainer does? If no, delete it.

Two banned shapes. The development diary: "previously", "used to", "this was changed because", "after the review", phase or campaign codes, any change-relative wording. History belongs in the commit message. The quick-to-stale tautology: restates the line below it, or enumerates the tests, classes or files that use the thing. It says what the code already said, and then rots.

Say what the thing is, not what it does. A comment on a type or member says what it is and why it has that shape, not what its methods do. A scaladoc on a difference says what the difference is, not which caller has it.

File Organization

A source file should read like a guided tour of the type. A contributor opening it for the first time learns, in order, what the type is, how to create it, how to use it, and only then how it works internally. Scaladocs set the context, method ordering tells the story, and section separators mark the chapters.

One Project per Class Name

Every module compiles into the same packages, kyo and kyo.internal, and private[kyo] does not narrow that: it is package visibility, and every module sits in that package. When two modules declare one fully qualified name, a classpath holding both keeps the first class and drops the other, and nothing reports it: not the JVM classloader, not the Scala.js linker, not the Scala Native linker.

A name in main sources belongs to one module, since users combine published modules in ways this build never does. In test sources the rule is narrower: two test classes under one name are fine until a test->test dependency puts them on one classpath, and the check fails on the change that does.

Name a platform facade after its module. kyo-http binds Node's path module as HttpNodePath because kyo-system already binds it as NodePath.

sbt 'checkClassNames JVM' compiles that platform and fails on a duplicate, naming both projects; the other arguments are JS, Native and Wasm. CI runs only the JVM row, which reuses the compile the doctest step already paid for; run the others locally when adding a class to js, js-wasm, native or wasm sources. Projects that produce one name by design, as kyo-compat's five bindings do, declare ClassNameCheck.classNameGroup; the check then accepts the shared name and instead fails if any project's classpath reaches two of them.

File Template

package kyo                           // or kyo.internal

import kyo.specific.imports
import scala.annotation.tailrec

/** Type-level scaladoc.
  *
  * Conceptual explanation.
  *
  * @tparam A description
  * @see [[kyo.Related]]
  */
sealed trait MyEffect[A] extends ArrowEffect[...] // or opaque type, final class

object MyEffect:

    // --- Public API (frequency-of-use order) ---

    // Suspend/create methods
    inline def create[A](...)(using ...): A < MyEffect[A] = ...

    // Query/access methods
    inline def get[A](...)(using ...): A < MyEffect[A] = ...
    inline def use[A](...)(using ...): B < (MyEffect[A] & S) = ...

    // Handler methods
    def run[A](...)(using ...): Result < S = runWith(...)(identity)
    private[kyo] def runWith[A](...)(f: ...)(using ...): B < S = ...

    // Factory methods (for resource types)
    def init[A](...)(using Frame): MyType[A] < (Sync & Scope) = ...
    def initWith[A](...)(f: ...)(using Frame): B < (Sync & Scope & S) = ...
    def initUnscoped[A](...)(using Frame): MyType[A] < Sync = ...

    // --- Nested Types ---

    object Unsafe:
        ...

    // --- Internal ---

    private[kyo] def internal(...) = ...

end MyEffect

Readability Ordering

The file template above reflects a deliberate top-to-bottom reading order:

  1. Type definition + scaladoc: the reader learns what the type is and why it exists
  2. Public API: organized into groups by usage pattern, most-used first within each group:
    1. Suspend/create methods
    2. Query/access methods
    3. Handler methods
    4. Factory methods (for resource types)
  3. Nested types: Unsafe, Config, auxiliary case classes, public type aliases
  4. Internal methods: implementation details, private[kyo] helpers, internal-only givens

Within each group, simple overloads come before complex ones. The canonical implementation sits next to its variants so the reader sees the delegation at a glance. Scaladocs on each method flow from one to the next, each building on context established by the previous.

This ordering matters because it determines how quickly a contributor can orient themselves. A well-ordered file answers "what does this do?" and "how do I use it?" without scrolling.

Visibility Tiers

ModifierScopeUse for
(none)PublicUser-facing API
private[kyo]Cross-packageInternal utilities used across modules
privateClass-localMutable state, helpers

Section Separators

Use // ---... separators with section names in all files:

// --- Generic ---

def foreach[...] = ...
def filter[...] = ...

// --- List ---

def foreach[A, B, S](source: List[A])(...) = ...

Group by semantic category (reads → writes → updates → handlers), then by arity within each group.

export for Nested Type Promotion

When a nested type is heavily used, promote it to package level with export:

// In Fiber.scala, after the companion object
export Fiber.Promise   // makes kyo.Promise available without Fiber. prefix

Use sparingly, only for types that users reference frequently enough that qualification would be noisy.

Macros

Never read a type's declaration through Symbol.tree. What that call returns depends on -Yretain-trees, a flag the user's build sets and the library does not control. Without it the compiler fabricates a declaration from the symbol info; with it the retained source declaration comes back instead. For a type member the two carry different things: the fabricated one carries the bounds, the real one carries the right-hand side as written, which for an opaque type is its alias and never mentions the bounds. A macro that matches on one shape crashes or answers differently under the other, and the tag or schema it derives stops agreeing with the one every other build derives.

Ask the symbol instead. kyo.internal.DeclaredBounds reads a type member's declared bounds through the node's own prefix, which answers the same in both modes, and substitutes the node's own type arguments into a parameterized bound.

Whether that call is stable is a per-symbol question, not a global one, because Symbols.retainsDefTree decides it as a disjunction: the flag is one disjunct, denot.owner.isTerm is another. For a type local to a term (a method's type parameter) the second holds unconditionally, since such a symbol dies with its term and cannot leak across runs, so both modes read the same declaration and reading it is sound. That case also has no prefix to be a member of, so it is the one place DeclaredBounds reads a tree, and it already handles it. For every other symbol the flag is the deciding disjunct, which is exactly what makes the general read unstable.

Reading a term definition's body (ValDef.rhs, a given's right-hand side) is different: those trees exist only under the flag, so such a macro must degrade gracefully rather than depend on them.

To check a macro answers the same in both modes:

KYO_RETAIN_TREES=true sbt 'kyo-dataJVM/test'

The flag is never set in CI. Retaining the trees of every dependency costs a few hundred MB per module, which the runners do not have to spare, so this is a local check to run when touching a macro that inspects types.


Module READMEs

Every module ships a README.md aimed at a developer evaluating or first using it. It positions the module, shows how to install and use it, and introduces features in an order a reader can stop at any time with a coherent picture. It is not a reference manual (scaladoc covers the full API) and not a tutorial (kyo-examples covers end-to-end programs).

To add or revise a module README, use the /readme skill. The skill is the authoritative source for both structure and writing style: it orchestrates source analysis, drafting, multi-axis critique, and doctest verification, and it carries the conventions every Kyo README follows (opening hook, capability summary, topical ordering, code-example rules, gotcha markers, and so on). kyo-http/README.md and kyo-schema/README.md are the working models the skill draws from.

sbt <module>/doctest validates that every fenced Scala block in the README compiles against the module's classpath. CI runs sbt doctest over the full project.


Optimization

Performance

  • Prefer final class for concrete types and abstract class over trait for base types: JVM interface dispatch is more expensive than class dispatch. Use trait only when defining a pure interface (like effect types extending ArrowEffect/ContextEffect)
  • Mark classes final unless sealed or abstract
  • Provide pure-function variants (mapPure, filterPure) when a hot transformation doesn't need suspension
  • Fast-path before slow-path: always check for degenerate cases (empty, single-element, already-resolved) before entering the general/expensive path:
    if source.isEmpty then Chunk.empty              // empty: return immediately
    else if source.sizeIs == 1 then
        f(source.head).map(Chunk(_))                // single: avoid Loop setup
    else Loop.indexed(...)                           // general case
    
  • Use opaque types; never wrap when you can alias. See Zero-Cost Type Design
  • Use inline strategically: inline creation paths, not handling paths. See Inline Guidelines
  • Prefer @tailrec loops; allocate continuations only when effects force suspension
  • Never block a thread: use Async-based suspension (Channel.put, Fiber.get, Clock.sleep) instead of blocking primitives (Thread.sleep, CountDownLatch.await, synchronized, Future.await).
  • Prefer lock-free algorithms (CAS + @tailrec) over blocking synchronization
  • Bit-pack atomically-updated composite state to avoid wrapper allocations. Always include a layout comment:
    // Bit allocation:
    // Bits 0-15 (16 bits): depth (0-65535)
    // Bit 16 (1 bit): hasInterceptor flag
    // Bits 17-63 (47 bits): threadId
    
  • Avoid the erased tag pattern. Tag handles variant effects, so existing usages are tech debt; do not introduce new instances:
    // tech debt: do not copy
    private inline def erasedTag[E]: Tag[Abort[E]] = Tag[Abort[Any]].asInstanceOf[Tag[Abort[E]]]
    

Zero-Cost Type Design

Kyo achieves zero-cost abstractions through opaque types. When designing a new type, choose the strategy that eliminates allocation on the hot path.

Union discriminability: When using union types, ensure all components are fully discriminable at runtime. Overlapping erasures will cause incorrect dispatch.

StrategyExampleWrapsWhen to use
Opaque over primitiveDuration = LongRaw primitiveNumeric quantities (time, size, count)
Opaque over JVM typeInstant = JInstantExisting classWrapping a well-tested JVM type with a safer/simpler API
Opaque over unionMaybe[A] = Absent | Present[A]Union of subtypesDiscriminated types where the success path avoids boxing
Opaque over arraySpan[A] = Array[? <: A]Mutable arrayImmutable view of array data without copying
Opaque over UnsafeChannel[A] = Channel.Unsafe[A]Unsafe implementationConcurrent types with safe/unsafe tiers (see Unsafe Boundary)
Effect aliasAsync <: (Sync & Async.Join)Subtype boundsComposing effects via subtype relationships
Subtype-bounded opaqueQueue.Unbounded[A] <: Queue[A] = Queue[A]Parent opaqueExpressing subtype relationships between opaque types

Structuring an opaque type:

  • Define the opaque type and its companion in the same file
  • Expose the safe API via extension methods in the companion, not methods on a class
  • Factory methods in the companion validate input: Maybe(null) returns Absent, Duration.fromNanos clamps negatives
  • Internal code accesses the underlying value via pattern matching on union members or direct use within the opaque boundary
  • Avoid exposing the underlying representation; if escape hatches are needed, use private[kyo]
  • Code inside the type's own scope cannot summon a Tag for the type or derive one mentioning its underlying type; it passes Tag.derive[X] explicitly. See Tags inside an opaque type's scope

Given instances for new types, as applicable:

  • CanEqual: required if the type supports ==/!= (strict equality is enabled project-wide)
  • Render: for human-readable display
  • Ordering: if the type is naturally sortable
  • Tag: automatically derived; never declare a given Tag[X] inside X's own scope (below)

Tags inside an opaque type's scope

Inside the template that declares an opaque type, and inside its companion, the compiler substitutes the underlying type for the opaque one wherever it has to infer, and it does so before any macro runs. Env.get[X] written there reaches the Tag macro as the underlying type, so the tag derived inside describes something different from the tag every call site outside derives, and a value stored under one is not found under the other.

Nothing at that point can say which type was meant, so the macro refuses any derivation whose type mentions the underlying type of an opaque type transparent there ([Tag.opaque.collapsed]), whether it was inferred or written. Naming the opaque type in Tag.derive[X] always survives the substitution and derives the same tag as anywhere else. An implicit query may or may not survive depending on how the compiler resolves it (an Emit.value(m) in a companion extension method on the opaque type does, a summoned Tag[X] does not); when it does not, the derivation is refused rather than misnamed, and the tag is passed explicitly:

opaque type Meters = Long

object Meters:
    def get: Meters < Env[Meters] = Env.get[Meters](using Tag.derive[Meters])

A given Tag[X] must not be defined inside X's scope ([Tag.opaque.given]): there it is also a Tag for the underlying type and would answer every such query with X's tag, silently. The same holds for an imported given or an inline given, which the macro cannot see; do not define them.

Refusal is per site, so a type like opaque type Count = Int refuses every Int on a tag surface in its own template. A scope that needs the underlying type on a tag surface keeps the opaque type in an object of its own.

Sealed trait vs opaque type:

  • Use opaque type when you want zero-cost wrapping of an existing representation
  • Use sealed abstract class or enum when you need pattern matching on cases or case class features (structural equality, copy). Reserve sealed traits for effect type hierarchies
  • Both can coexist: Result is an opaque union whose members (Success, Failure, Panic) are sealed subtypes

Inline Guidelines

inline creates code bloat when overused. The codebase follows a deliberate strategy: inline the creation path (where effects are born), not the handling path (where effects are processed), and inline function parameters to avoid closure allocation.

IMPORTANT: Inline as little as possible. When a method needs inline, mark only the function/by-name parameters as inline and keep the method body small. This eliminates closure allocation while minimizing code bloat at each call site. Two patterns:

  1. Trivial body: the method body is a one-liner, so inlining the whole thing is fine:

    // Maybe.map: body is a simple branch, no bloat risk
    inline def map[B](inline f: A => B): Maybe[B] =
        if isEmpty then Absent else f(get)
    
  2. Non-trivial body: define a local non-inline def for the real logic and call it once. The inline method removes the lambda allocation for f, whose body lands inside the local method, and the loop or recursion stays a method call instead of expanding into the caller's control flow. A schematic shape (Pending.map follows it; its kernel internals are covered in kyo-kernel's guide):

    inline def map[B, S2](inline f: A => B < S2)(using inline frame: Frame): B < (S & S2) =
        @nowarn("msg=anonymous") def loop(v: A < S): B < (S & S2) =
            ... // suspension handling, recursing into loop, applying f to a value
        loop(self)
    

When in doubt, don't inline. The cost of unnecessary inlining (code bloat, slower compilation) is higher than the cost of a method call on a non-hot path.

DO inline:

CategoryExamplesWhy
Effect suspend/create callsAbort.fail, Var.set, Emit.value, Choice.evalSeqDirect calls to ArrowEffect.suspend/suspendWith; must be zero-cost
Kernel framework entry pointsArrowEffect.handleLoop, ArrowEffect.suspendBackbone of the effect system; enables compile-time specialization
private[kyo] hot-path helpersVar.runWith, internal handler implementationsOptimizes internal glue without affecting public API size
Methods with function/by-name parametersMaybe.map, Maybe.flatMap, Var.use, Result.foldEliminates Function1 allocation: the lambda body is substituted directly at the call site

DO NOT inline:

CategoryExamplesWhy
Public effect handlers/runnersAbort.run, Var.run, Emit.runFold, Choice.runThese call back into the kernel; inlining would duplicate complex handler logic at every call site
Methods that take only value parametersMaybe.get, Maybe.zip, Maybe.contains, Maybe.toListNo function/by-name parameters means no closure to eliminate, so inlining adds bloat without benefit

The pattern in practice, in an effect like Abort:

  • Abort.fail, Abort.panic, Abort.get, Abort.when → inline (creation)
  • Abort.run, Abort.recover, Abort.fold → not inline (handling)
  • Abort.runWith → inline: a handler, but it takes a continuation, so the function-parameter rule applies

Similarly for data types like Maybe:

  • Maybe.map, Maybe.flatMap, Maybe.filter, Maybe.getOrElse → inline (simple branches)
  • Maybe.get, Maybe.zip, Maybe.contains, Maybe.flatten → not inline (complex logic)

@nowarn for inlined lambdas: when inlining a function parameter causes the compiler to warn about new anonymous classes, use @nowarn("msg=anonymous").


Testing

Deterministic Tests

A test must pass or fail on the code's behavior, never on how fast the machine ran. A timing-dependent assertion is a broken test: it flakes on a loaded CI runner, a fast laptop, or a slow emulator.

No test may depend on the real clock. No assertion on measured elapsed time (currentTimeMillis/nanoTime, Clock.now/nowMonotonic deltas) against a threshold; no Thread.sleep to "give something time" then asserting it happened; no real-time delay or timeout as the pass condition. Coordinate with barriers, and control durations with virtual time.

A threshold is a defect whatever it measures (elapsed time, clock skew, latency, memory, an iteration count under load): a number that passes on your machine fails on a slow, emulated, or contended one, and widening it only lowers the flip rate. Do not tune thresholds. Replace the magnitude with a property a fast or slow runner cannot flip:

  • Bracketing: to check a reading matches a reference, sample the reference on both sides: before = ref(); v = read(); after = ref(); assert(before <= v && v <= after). A slower host only widens the interval; a wrong value falls outside, and no tolerance appears. This is how to check a clock or epoch binding.
  • Ordering / monotonicity: assert(b >= a) across successive reads, or that entries arrived in order.
  • State / count: assert(consumed + remaining == total), assert(peerClosedFlag).

Virtual time (Clock.withTimeControl): the clock advances only when the test tells it to, so sleeps, delays, timeouts, schedules, and stopwatches become exact. Drive a sleeping effect by forking it alongside an advancer and joining; assert exact durations (elapsed == 5.seconds, never >= 5.seconds). TimeControl gives set, advance, and awaitPendingSleepers(n) (advance only after n sleepers register, so the tick count is exact rather than a function of interleaving).

Clock.withTimeControl { control =>
    for
        fiber    <- Fiber.initUnscoped(theEffectThatSleeps)
        advancer <- Fiber.initUnscoped(Loop.forever(control.advance(1.milli)))
        result   <- fiber.get          // completes only once virtual time reaches its deadlines
        _        <- advancer.interrupt
    yield assert(deterministicProperty)
}

Barriers, not sleeps, to make one fiber wait for another: Latch (a one-shot countdown: Latch.init(n) opens after n releases, which also covers "wait until N arrived"), or Channel/Fiber.get (rendezvous). A negative property ("X must NOT have happened yet") is proven by a barrier the other fiber would have had to pass, never by sleeping and checking.

Common conversions:

Flaky shapeDeterministic replacement
assert((currentTimeMillis - start) >= d)run under withTimeControl; assert on calls/state and/or stopwatch.elapsed == d
Thread.sleep(n); assert(done)release a Latch/Channel from the other fiber and await it
assert(elapsed < budget) (op returns fast)assert the terminal event/state that proves it returned, not the elapsed
retry/schedule/backoff timingwithTimeControl plus an advancer; assert attempt count and exact virtual elapsed
"settle" sleep before readingwait on the settle's own completion signal
real-thread concurrent soakbound each worker by a fixed op count and self-terminate; a producer/consumer uses a producers-done latch plus drain-until-done, asserting conservation, never the window

Legitimately not the real clock (fine to keep): sleeps, delays, and timeouts under withTimeControl; Duration arithmetic; a generous ceiling used only as a deadlock or hang canary, where the assertion reads a state and the ceiling exists so a hang trips the suite timeout rather than being asserted on.

Deviations. Some tests exercise a seam virtual time cannot cover: the platform clock itself, a real OS socket or kernel poller, a spawned subprocess, raw threads below the effect system. The absence of a virtual-time seam is not a license to keep a timing assertion: the pass/fail must still assert a state, event, structure, or monotonicity, never measured elapsed. A timeout is legitimate only as a hang-canary ceiling, never the pass condition. Before any magnitude bound, prove no ordering, bracketing, or state proxy exists; only then does a catastrophic-only bound survive (so wide that only a genuine defect trips it), written at the site as // deviation: <reason> and reported to the maintainer. A test that deliberately wedges the scheduler (a livelock repro) cannot rely on the per-leaf timeout, which runs on that same scheduler; a raw watchdog thread is then legitimate only if its firing is gated on a completion latch the test releases when it finishes, so a slow-but-correct run releases the latch first and is never disturbed.

The per-leaf timeout already guards hangs. Every kyo.test.Test suite caps each leaf: TestBase.timeout defaults to 120s (Infinity only when a debugger is attached), and a suite may tighten it (kyo.net.Test uses 60s). An in-test Async.timeout(d)(op) added only to turn a hang into a failure duplicates that cap, and a too-tight one is itself a flake: it fires on a slow-but-progressing run. Keep an in-test timeout only when its value is the tested behavior (an asserted deadline), the test asserts the window expires, or the suite is raw ScalaTest with no framework cap, which then needs its own catastrophic watchdog, latch-gated as above.

A test's timers are a system: widen them together. When a test keeps multiple real deadlines (a production deadline plus the ceiling that observes it, a pacer plus the await that watches its reap), they form one system. Widening one alone breaks the ordering the discrimination depends on: a ceiling shorter than the deadline it observes races the very event it exists to catch, and a "must complete before X" budget left tight while X is widened deletes the coverage. Scale them together, preserving every a < b the test relies on.

The production-deadline race. A recurring flake shape: a test arms a short production deadline (a handshake or connect timeout, a cancel budget, a container stop-timeout, an fd-drain window) and then its own setup or observation must win a race against that live timer, measuring no elapsed time, so a textual scan misses it. Fix with one of: (a) a barrier on the event the deadline produces (a reap completion, a close promise, a marker file); (b) the production deadline scaled far above the operation's real cost so it fires only on a genuine hang, a documented catastrophic margin; (c) virtual time, when the deadline is engine-internal and Clock-driven. Virtual time is preferred but not always reachable: when the deadline is reset across a seam with no sleep to fence, a withTimeControl advancer races past it, and the coupled-margin fallback (b) is correct. Confirm which one holds by running the converted leaf, never by reasoning alone.

Checklist before adding a timing-touching test: does the assertion depend on real elapsed time (if so, move it under withTimeControl); is a Thread.sleep or bare Async.sleep used to coordinate (replace with a barrier); is a duration or timeout the pass condition (assert the state or event it produces instead); is an in-test timeout there only to catch a hang on a kyo.test.Test suite (remove it, the per-leaf cap already does that); if the real clock is unavoidable, is the deviation commented and reported.

Framework

Test suites extend kyo.test.Test[Any], directly or through a module base that extends it. The type parameter is an additive extra effect row; Any is the common case (baseline Async & Abort[Any] & Scope only). Test files are named FooTest.scala and mirror the main source structure.

A module base exists only when the module's suites share configuration or a fixture (a tighter timeout, an aroundLeaf, a helper). It lives in the module's test sources, as a Test.scala in the module's package or as a named base such as ParseTestBase, and extends kyo.test.Test[Any].

A project runs on kyo-test when its build definition registers kyo-test's framework: a cross project calls .withKyoTest, and a JVM-only project adds the same runner wiring by hand. Projects without that wiring test on ScalaTest, apart from kyo-zio-test, which tests on the zio-test framework it integrates. kyo-kernel is the ScalaTest project to know, since its suites assert on the scheduler state kyo-test would run them on (see kyo-kernel's guide). Everything else in this section describes kyo-test.

class ChannelTest extends kyo.test.Test[Any]:
    "put and take" in {
        for
            channel <- Channel.init[Int](2)
            _       <- channel.put(1)
            v       <- channel.take
        yield assert(v == 1)
    }

Modules that share a common fixture define a thin abstract base:

// e.g. kyo-parse
abstract class ParseTestBase extends kyo.test.Test[Any]

class ParserTest extends ParseTestBase:
    "parse empty" in {
        assert(parse("") == Result.unit)
    }

Test Patterns by Level

Pure / synchronous: no effects, leaf body is Unit:

"value equality" in {
    assert(Maybe(42) == Maybe(42))
}

Effectful: the leaf body is Unit < (Async & Abort[Any] & Scope); Kyo effects compose directly with no run wrapper:

"put and take" in {
    for
        channel <- Channel.init[Int](2)
        _       <- channel.put(1)
        v       <- channel.take
    yield assert(v == 1)
}

Groups are registered with - (always a group; body runs at registration time to collect nested leaves):

"channel" - {
    "bounded" - {
        "put and take" in { ... }
    }
}

Assertion Model

assert(cond) is a power-assert macro: on failure it prints a diagram of subexpression values. Every leaf must evaluate at least one assertion; a leaf that completes without any assertion is failed by default (the failOnNoAssertion check). To explicitly mark a leaf as asserting no runtime value, write succeed (or succeed("why")) in the body.

To disable the check for a whole suite:

override def config = super.config.failOnNoAssertion(false)

Compile-Time Tests

Verify that code compiles or fails to compile:

"valid code compiles" in {
    typeCheck("Env.get[Int]")
}

"invalid code fails with expected message" in {
    typeCheckFailure("Layer.init[String]()")("Missing Input: scala.Predef.String")
}

The string passed to typeCheckFailure is a substring match against the compiler error.

Concurrent Test Helpers

assertEventually(cond) retries cond every 10ms until it yields true, bounded by the per-test timeout. Use it for eventually-consistent concurrent state instead of sleeps:

"counter reaches 1" in {
    for
        counter <- AtomicInt.init(0)
        _       <- Fiber.init(counter.incrementAndGet.unit)
        _       <- assertEventually(counter.get.map(_ > 0))
    yield ()
}

Debugging Hangs and Timeouts

When a leaf stalls past the heartbeat interval or hits its timeout, the runner captures a full thread dump and every registered diagnostic snapshot alongside the failure. This makes a rare hang or deadlock root-causable from a single occurrence, rather than needing a second, instrumented reproduction.

Runtime components publish their live internal state through the process-global registry kyo.internal.Diagnostics. Register a named dumper (typically at construction) and close the registration when the component shuts down so it does not accumulate across a long-lived process:

private val diag =
    Diagnostics.register("ConnectionPool@" + java.lang.System.identityHashCode(this)) { () =>
        s"active=${active.size} idle=[${idleKeys.mkString(" ")}]"
    }
// on shutdown:
diag.close()

A dumper runs on the reporter's thread, concurrently with the component it inspects, so it must read state best-effort (a stale or partial snapshot is acceptable) and must never block. An I/O driver, for example, can register its pending-operation tables and poll/reap liveness counters this way.

Exception and Failure Assertions

intercept[E](body) asserts that body throws an exception of type E and returns the caught exception:

"throws on invalid input" in {
    val ex = intercept[IllegalArgumentException] {
        parseStrict("")
    }
    assert(ex.getMessage.contains("empty"))
}

Skipping and Preconditions

assume(cond, msg) cancels (not fails) the test when a precondition is not met. cancel(msg) cancels unconditionally. Use these for platform-specific prerequisites rather than guarding with if:

"requires multiple cores" in {
    assume(Runtime.getRuntime.availableProcessors > 1, "requires multi-core")
    // ...
}

Disabling and Focusing Tests

These decorators chain on a leaf name before in:

"not written yet" .ignore in { ... }
"reconnect after reset" .ignore("issue #42: deadlocks the pool") in { ... }
"iterating on this one" .focus in { ... }
"needs a database" .only(databaseAvailable) in { ... }
  • .ignore registers the leaf, never runs its body, and reports it Ignored. .ignore(reason) records why; give the reason whenever a test is disabled.
  • .focus runs only the focused leaves; every other leaf reports Skipped. It is an editing aid, not something to commit.
  • .only(cond) runs the leaf only when cond is true at registration; otherwise it reports Skipped.
  • To keep running a known-broken body, use .pendingUntilFixed(reason) (see Decorators).

Platform-Conditional Tests

Platform gates restrict a leaf or group to one or more platforms. On a disabled platform the body is compile-excluded (absent, not skipped). .jvm, .js, .native and .wasm enable a platform; .notJvm, .notJs, .notNative and .notWasm disable one. Wasm counts as JS: .js includes Wasm and .notJs excludes it, while .wasm and .notWasm select Wasm alone. So .notNative still runs on Wasm. Chained gates intersect:

"jvm only" .jvm in { ... }
"not native" .notNative in { ... }            // JVM, JS and Wasm
"jvm or js" .notNative.notWasm in { ... }

Decorators

Decorators chain on a leaf name before in:

"flaky network call" .flaky in { ... }          // retry up to 3x, tags "flaky"
"slow integration" .timeout(120.seconds) in { ... }
"known broken" .pendingUntilFixed("issue #42") in { ... }
"retry on failure" .retry(3) in { ... }

Per-Suite Configuration

Override config to control parallelism, timeout, and other run settings:

// Run all leaves in this suite sequentially
override def config = super.config.sequential

// Change the default per-leaf timeout
override def timeout = 30.seconds

Override aroundLeaf to wrap every leaf with shared setup or teardown:

override def aroundLeaf[A](body: A < (Async & Abort[Any] & Scope))(using Frame) =
    HttpClient.withConfig(_.timeout(60.seconds))(body)

Unsafe Boundary

The Two-Tier API Pattern

Every concurrent type (Channel, Queue, Hub, Fiber, Meter, Latch, Signal, etc.) exposes two parallel APIs: a safe tier that tracks effects in the type system, and an Unsafe tier for integrations, libraries, and performance-sensitive code that bypasses effect tracking.

Users can always navigate between the two tiers:

  • .unsafe on any safe instance returns the Unsafe counterpart
  • .safe on any Unsafe instance returns the safe counterpart

The two tiers mirror each other: every operation available on the safe API has an Unsafe equivalent, and vice versa. The safe tier wraps operations in effects (Sync, Abort[Closed], Async), while the Unsafe tier returns raw values and Results, guarded by (using AllowUnsafe).

Structure: For a type T, expect to find:

  • T: the safe type with effectful methods (takes using Frame)
  • T.Unsafe: the form depends on the type: sealed abstract class when multiple implementations are needed (e.g., open/closed states), or opaque type for zero-cost wrapping of a single Java/platform type (e.g., AtomicInt.Unsafe = AtomicInteger). Operations take (using AllowUnsafe) instead of (using Frame)
  • T.Unsafe.init(...): factory in the Unsafe companion (takes using AllowUnsafe; add Frame only if the factory uses it, e.g., to capture creation context for Closed). Unsafe methods should return raw values or Results, not effectful computations (A < S). Frame in unsafe code is primarily for capturing creation context, not for effect suspension
  • T.init(...) / T.use(...) / T.initUnscoped(...): safe factories that delegate to Unsafe.init with lifecycle management (see the init family in Naming and Closeable Resource Pattern)

Safe→Unsafe bridge: Safe methods enter the unsafe tier via Sync.Unsafe.defer { ... }, which provides AllowUnsafe implicitly. Inside, they call the Unsafe method and wrap Result[Closed, A] in Abort.get to convert to Abort[Closed] effect.

Subtypes preserve the pattern: Queue.Unbounded has Queue.Unbounded.Unsafe that extends Queue.Unsafe. The subtype relationship holds on both tiers.

Unsafe API Conventions

  • WARNING scaladoc on every Unsafe class and object Unsafe, always the same text: "Low-level API meant for integrations, libraries, and performance-sensitive code. See AllowUnsafe for more details." Skip detailed method-level scaladocs for Unsafe APIs; the WARNING class-level scaladoc is sufficient. Add method-level docs only when the behavior is non-obvious or differs from the safe counterpart.
  • (using AllowUnsafe) on every method that performs side effects without suspension. Pure accessors like capacity don't need it.
  • extends Serializable on sealed abstract class Unsafe for closeable types
  • Closeable operations return Result[Closed, A]; never throw on closed state
  • Factory methods in object Unsafe take (using AllowUnsafe). Add Frame only when the factory actually uses it (e.g., to capture the creation context for Closed errors)

AllowUnsafe Tiers

All side effects must be suspended. No side-effecting code should execute outside of Kyo's effect system without either an AllowUnsafe proof or a suspension boundary like Sync.Unsafe. This is a hard rule: unsuspended side effects break referential transparency.

In order of preference:

  1. Propagate the proof: the caller explicitly opts in (preferred for performance, since there is no suspension overhead):

    def init[A](capacity: Int)(using AllowUnsafe): Queue.Unsafe[A]
    
  2. Suspend in Sync: wraps the unsafe operation in an effect:

    def offer(v: A)(using Frame): Boolean < (Sync & Abort[Closed]) =
        Sync.Unsafe.defer(Abort.get(self.offer(v)))
    
  3. Import danger: external runtime callbacks (Netty listeners, platform interop), application boundaries (KyoApp, tests), and initialization of globally shared module-level values that need unsafe operations at class loading time (e.g., Clock.live, which creates its executor there):

    import AllowUnsafe.embrace.danger
    

Scope AllowUnsafe as narrowly as possible. Never place (using AllowUnsafe) on a constructor or class-level import where it leaks to all methods: this masks accidental unsafe operations that the compiler would otherwise catch. Instead, take it only on the specific methods that need it, or scope the import to the smallest block possible:

// Wrong: leaks AllowUnsafe to every method in the class
class MyConnection(...)(using AllowUnsafe):
    private val flag = AtomicBoolean.Unsafe.init()
    def send(...) = ... // compiler won't catch unsafe ops here

// Right: scoped to the specific initialization
class MyConnection(..., closed: AtomicBoolean):
    def isAlive(using AllowUnsafe): Boolean = !closed.unsafe.get()
    def send(...) = ... // compiler catches accidental unsafe ops

Prefer the safe type, access .unsafe when needed. When a type has safe/unsafe tiers, hold the safe version and use .unsafe only in methods that already have AllowUnsafe in scope. This ensures the compiler enforces safety by default:

// Preferred: hold safe type, access .unsafe in AllowUnsafe methods
class MyConnection(closed: AtomicBoolean):
    def isAlive(using AllowUnsafe): Boolean = !closed.unsafe.get()
    def close(using Frame): Unit < Async = closed.set(true).andThen(...)

// Avoid: holding Unsafe type bypasses safety checks everywhere
class MyConnection(closed: AtomicBoolean.Unsafe):
    def isAlive(using AllowUnsafe): Boolean = !closed.get()

AllowUnsafe for Zero-Allocation Side Effects

AllowUnsafe is a compiler-enforced proof that the side effect has already been suspended at an outer scope. Methods can perform side effects directly without allocating a Sync.Unsafe suspension, while the proof guarantees the call chain is rooted in a properly suspended context.

This is the mechanism behind the safe→unsafe bridge. The safe tier suspends via Sync.Unsafe, which provides AllowUnsafe implicitly, then calls the Unsafe method that performs the side effect directly:

// Safe tier: suspends once, then delegates
def set(v: Boolean)(using Frame): Unit < Sync = Sync.Unsafe.defer(unsafe.set(v))

// Unsafe tier: performs the side effect directly, no allocation
// The AllowUnsafe proof guarantees an outer scope has already suspended
extension (self: Unsafe)
    inline def set(v: Boolean)(using AllowUnsafe): Unit = self.set(v)

The same pattern applies to internal APIs. When a method takes (using AllowUnsafe), it's declaring: "I perform side effects, but I trust my caller to have suspended." This allows multiple unsafe operations to compose without each one wrapping in its own Sync.Unsafe:

// One suspension covers multiple unsafe operations, with no per-operation allocation
def release(conn: Connection)(using Frame, AllowUnsafe): Unit =
    if conn.isAlive then          // unsafe: reads atomic flag
        idleChannels.offer(conn)  // unsafe: mutates concurrent queue
    else
        conn.closeAbruptly()      // unsafe: closes connection

Without AllowUnsafe, each of these would need its own Sync.Unsafe wrapper, allocating a closure each time. The AllowUnsafe proof eliminates this overhead by hoisting the suspension to the outermost boundary.

Closeable Resource Pattern

All closeable resources follow:

// Closed (kyo-kernel) carries creation context for diagnostics
final class Closed(resource: String, createdAt: Frame, details: String = "")(using Frame)

// Unsafe level returns Result[Closed, A]
def offer(v: A)(using AllowUnsafe): Result[Closed, Boolean]

// Safe level converts to Abort[Closed]
def offer(v: A)(using Frame): Boolean < (Sync & Abort[Closed]) =
    Sync.Unsafe.defer(Abort.get(self.offer(v)))

Resource factory convention: all variants delegate down to Unsafe.init. The close each variant registers is the type's own choice; Channel below registers close with its Scope and closeDiscard in use.

MethodLifecycleEffect SetDelegates to
initScope-managed cleanupSync & ScopeinitWith(identity)
initWith(f)Scope-managed + callbackSync & Scope & SUnsafe.init + Scope.ensure(close)
use(f)Bracket (no Scope needed)Sync & SUnsafe.init + Sync.ensure(close)
initUnscopedNo cleanup guaranteesSyncinitUnscopedWith(identity)
initUnscopedWith(f)No cleanup + callbackSync & SBare Unsafe.init

The delegation chain (using Channel as canonical example):

// init delegates to initWith with identity: Scope-managed lifecycle
def init[A](capacity: Int, access: Access)(using Frame): Channel[A] < (Sync & Scope) =
    initWith[A](capacity, access)(identity)

// initWith: create resource, register Scope cleanup, apply callback
inline def initWith[A](capacity: Int, access: Access)[B, S](
    inline f: Channel[A] => B < S
)(using inline frame: Frame): B < (S & Sync & Scope) =
    Sync.Unsafe.defer:
        val channel = Unsafe.init[A](capacity, access)
        Scope.ensure(Channel.close(channel)).andThen:
            f(channel)

// use: bracket semantics via Sync.ensure, no Scope in the effect set
inline def use[A](capacity: Int, access: Access)[B, S](
    inline f: Channel[A] => B < S
)(using inline frame: Frame): B < (S & Sync) =
    Sync.Unsafe.defer:
        val channel = Unsafe.init[A](capacity, access)
        Sync.ensure(Channel.closeDiscard(channel)):
            f(channel)

// initUnscoped delegates to initUnscopedWith with identity: no cleanup
def initUnscoped[A](capacity: Int, access: Access)(using Frame): Channel[A] < Sync =
    initUnscopedWith[A](capacity, access)(identity)

// initUnscopedWith: bare Unsafe.init, no cleanup registered
inline def initUnscopedWith[A](capacity: Int, access: Access)[B, S](
    inline f: Channel[A] => B < S
)(using inline frame: Frame): B < (S & Sync) =
    Sync.Unsafe.defer(f(Unsafe.init[A](capacity, access)))

Choose init (Scope-managed) by default. Use use when you want bracket semantics without Scope in the effect set. Use initUnscoped only when the caller manages lifecycle manually.

Close Method Convention

The close variants depend on what the resource holds when it closes.

A resource whose shutdown has a drain window (in-flight requests, open connections) provides three variants. The parameterized version is the canonical implementation; the others delegate to it:

// Canonical: takes an explicit grace period
def close(gracePeriod: Duration)(using Frame): Unit < Async

// Default: delegates with a default grace period
def close(using Frame): Unit < Async = close(30.seconds)

// Immediate: delegates with zero grace period
def closeNow(using Frame): Unit < Async = close(Duration.Zero)

This gives callers control without forcing them to pick a timeout for the common case, and closeNow states that a close does not wait.

A resource that buffers elements (Channel, Queue, Hub) has no grace period. Its close returns the elements it still held (Maybe[Seq[A]] < Async), and closeDiscard drops them and stays in Sync. Channel and Queue also provide closeAwaitEmpty, which closes to new elements and completes once the remaining ones are consumed.

Local-Backed Service Pattern

Types like Clock, Log, Random, Console, System, and HttpClient follow a common pattern: a Local holds a default instance, and the companion exposes get/use/let to interact with it. Convenience methods on the companion delegate through the local so callers never need the instance directly.

final case class Clock(unsafe: Clock.Unsafe):
    // Instance methods operate on `this`
    def now(using Frame): Instant < Sync = ...

object Clock:
    private val local = Local.init(live)

    // Access the local instance
    def get(using Frame): Clock < Any = local.get
    def use[A, S](f: Clock => A < S)(using Frame): A < S = local.use(f)

    // Swap the instance for a scope
    def let[A, S](c: Clock)(f: => A < S)(using Frame): A < S = local.let(c)(f)

    // Convenience: delegates through the local
    def now(using Frame): Instant < Sync = ...
    def sleep(duration: Duration)(using Frame): Unit < Async = ...

Key points:

  • The Local is private; external code uses only get/use/let
  • Companion convenience methods delegate to the local instance so most callers never call get or use explicitly
  • let enables testing by substituting a mock/controlled instance
  • The live default means the service works out of the box with no setup

KyoException Convention

All custom exceptions extend KyoException, which provides:

  • NoStackTrace for performance (stack traces are expensive and rarely useful for expected errors)
  • Frame-based context that captures the creation site
  • Environment-aware formatting (rich ANSI in dev, minimal in prod)
final class Closed(resource: String, createdAt: Frame, details: String = "")(using Frame)
    extends KyoException(render"$resource created at ${createdAt.position.show} is closed.", details)

Follow this pattern for all new exception types:

  • Extend KyoException, not Exception or RuntimeException. KyoException already mixes in NoStackTrace, so a subclass does not need to add it
  • Take (using Frame) to capture context
  • Use String for messages
  • Keep the message concise; the Frame provides the location context

Effect Implementation Reference

NOTE: This section is a reference for the uncommon task of implementing a new effect. For everyday coding, the sections above are sufficient.

Anatomy of an Effect

The kernel side of this work (handler variants, region rows, context strategies, isolates) is covered in depth by kyo-kernel's guide; the scaladoc on each ArrowEffect and ContextEffect handler states its contract.

  1. Type definition: a sealed trait extending one of two base classes:

    • ArrowEffect[I, O]: for function-like effects that transform inputs to outputs. Operations are encoded as an ADT and answered by a handler. Used by Abort, Var, Emit, Choice, Poll.
    • ContextEffect[A]: for value-providing effects (dependency injection). No ADT or handler loop is needed: ContextEffect.handleInheritable(tag, value)(computation) binds a value that forked computations inherit, ContextEffect.handleNonInheritable(tag, value)(computation) binds one that a fork does not carry, and ContextEffect.handle takes explicit fork and join strategies. Used by Env and by Local, whose per-local fork strategy (Local.init(default)(forkValue), where Absent means not inherited, and Local.initNoninheritable) lives in the value it binds.
    sealed trait Var[V] extends ArrowEffect[Const[Op[V]], Const[V]]   // ArrowEffect
    sealed trait Env[+R] extends ContextEffect[TypeMap[R]]            // ContextEffect
    
  2. Operations as data: encode operations as an ADT, not as methods:

    object internal:
        type Op[V] = Get.type | V | Update[V]
        object Get
        abstract class Update[V]:
            def apply(v: V): V
    

    Update is a class rather than a V => V alias so the handler's case input: Update[V] stays distinguishable from case input: V (see the union discriminability rule in Zero-Cost Type Design).

  3. Suspend: translate domain operations into kernel inputs:

    inline def get[V](...): V < Var[V] = use[V](identity)
    inline def use[V](...)(inline f: V => A < S)(...) =
        ArrowEffect.suspendWith[V](tag, Get: Op[V])(f)
    inline def set[V](inline value: V)(...) =
        ArrowEffect.suspend[Unit](tag, value: Op[V])
    
  4. Handle: choose the kernel handler by what the clause needs (kyo-kernel's "Recipe: choose and write the handler" covers the *With and recover forms):

    HandlerWhen to use
    ArrowEffect.handleContThe clause holds the continuation and applies it at most once, or not at all (early exit)
    ArrowEffect.handleContRepeatedThe clause applies the continuation more than once (backtracking, non-determinism)
    ArrowEffect.handleLoopAnswer each occurrence with Loop.continue(answer), no state between occurrences
    ArrowEffect.handleLoopStateThread state between occurrences with Loop.continue(nextState, answer)

    Each has an overload with a done clause that transforms the region's final value. Var.runWith threads the variable through handleLoopState:

    ArrowEffect.handleLoopState(tag, state, v)(
        [C] =>
            (state, input) =>
                input match
                    case input: Get.type =>
                        Loop.continue(state, state)
                    case input: Update[V] @unchecked =>
                        val nst = input(state)
                        Loop.continue(nst, nst)
                    case input: V @unchecked =>
                        Loop.continue(input, state),
        done = f
    )
    

    To answer a failure of the handled body, use the overload that adds a recover: Throwable => Maybe[B < (S & S2)] clause. Converting exceptions from user code into effect errors is Abort.catching.

  5. Reducible.Eliminable given if the effect can be fully eliminated:

    given eliminateAbort: Reducible.Eliminable[Abort[Nothing]] with {}
    
  6. Reducible in handler signatures: when an effect has union types (e.g., Abort[E | ER]), handlers use Reducible to allow partial handling. The handler eliminates E and reduces the remaining ER via reduce.SReduced:

    def run[E](using Frame)[A, S, ER](
        v: => A < (Abort[E | ER] & S)
    )(using
        ct: ConcreteTag[E],
        reduce: Reducible[Abort[ER]]
    ): Result[E, A] < (S & reduce.SReduced)
    

    This lets callers handle one layer of a union error type while preserving the rest in the effect stack.

  7. Variants delegate to canonical; never duplicate handler logic:

    def run[V, A, S](state: V)(v: A < (Var[V] & S)): A < S =
        runWith(state)(v)((_, result) => result)
    def runTuple[V, A, S](state: V)(v: A < (Var[V] & S)): (V, A) < S =
        runWith(state)(v)((state, result) => (state, result))
    

Delegation Pattern for Higher-Level Types

Higher-level types delegate to lower-level ones instead of reimplementing them:

HigherDelegates toAdds
ChannelQueueFiber-aware put/take with suspension
HubChannel + FiberFan-out distribution fiber
AsyncFiberStructured concurrency, isolation
StreamEmit[Chunk[V]]Chunked processing, transformations
Meter.pipelineSeq[Meter]Composed admission control

Build new types by composing existing ones. Create anonymous instances when composition logic varies:

def pipeline[S](meters: Seq[Meter < (Sync & S)])(using Frame): Meter < (Sync & S) =
    Kyo.collectAll(meters).map { seq =>
        new Meter:
            def run[A, S](v: => A < S)(using Frame) = ...
    }

Isolate Protocol for Fiber-Crossing Operations

Methods that fork computations carrying arbitrary effect state (A < (Abort[E] & Async & S)) into new fibers must use the three-step Isolate protocol to safely move effectful state across fiber boundaries. When the forked computation has a concrete type with no extra effects (e.g., Int < Async), no Isolate is needed because there is no effect state to transfer across the fiber boundary.

A schematic of the three steps, not the code of any one method. Async.race takes the Isolate in this position and passes it to Fiber.internal.race, which captures once and runs each isolated computation inside its own fiber:

def forkAll[E, A, S](
    using isolate: Isolate[S, Abort[E] & Async, S]   // Isolate comes first in using clause
)(computations: Seq[A < (Abort[E] & Async & S)])(using Frame): A < (Abort[E] & Async & S) =
    isolate.capture { state =>                          // 1. capture current state
        forkEach(computations.map(isolate.isolate(state, _)))   // 2. attach state to each forked computation
            .map(winner => isolate.restore(winner))     // 3. restore state from the completed fiber
    }

Note the using parameter ordering: Isolate precedes Frame because it participates in type inference.

Effects that carry state across fibers provide standard isolate strategies in an object isolate namespace in their companion:

  • update: the forked fiber gets the current state; on completion, the outer state is replaced with the forked fiber's final state
  • merge(f): combines the outer and inner states using a merge function f
  • discard: the forked fiber gets the current state but its final state is thrown away

Example: Var.isolate.update, Var.isolate.merge((a, b) => a + b), Var.isolate.discard.