Services And Effects
Unified Model
One abstraction for runtime context: an effect — named operations interpreted by a handler/provider at a scope. A service is one kind of effect.
| Kind | Declaration | Discharged by | Resumption | Lifecycle | Examples |
|---|---|---|---|---|---|
| service | effect service Database { … } | provide | tail-resumptive (plain call) | yes (t/init/drop); may be ambient | Database, Email, Clock |
| control | effect control Yield { … } | provide (handler) | one-shot resume/return | no | Yield, Async, Choice |
| command | effect command HttpFetch { … } | provide (runtime) | runtime-interpreted | runtime-owned | TEA Command, HttpFetch |
provide installs an interpreter and subtracts a label from the effect channel. A function's eff row is the unified effect channel; typed exceptions remain the separate no-resume sibling (exn — 08-errors.md).
Parses today:
effect service,let service.effect control/effect commandreserved, not implemented.
Local mutation is not an effect. Builders and scoped editors (Vector.edit, Map.edit, Set.edit) that cannot escape do not enter eff. Observable/cross-boundary state uses services/resources — see 01-syntax.md, book/008-Collections.shp.
Permanently rejected: State(...) and friends
Ship will never add a generic mutable-state effect — no State('s), runState, Koka state/ref, or Ref.get/Ref.set over effects. Use mut, builders, scoped editors, services/resources, and later locality/uniqueness. Do not reopen this. See decisions.md.
Inference: Modal Effect Types
Effect channels are part of the typing judgment (Modal Effect Types, OOPSLA 2025), not copied through explicit row variables on every HOF:
App code carries no routine effect annotations;
effshows at boundaries.Ambient services are hidden from ordinary signatures.
Higher-order combinators stay annotation-free and accept effectful callbacks:
let stamped = Vector.map(users, f = (u) => (u, Clock.now()));
// call performs Clock; `map` needs no effect annotation
Callback Channel Flow In .shc
// Relative Current flow: callback channels join the call expression
let map: pure fn!('a, 'b)(
items: Vector('a),
f: fn('a) -> 'b,
) -> Vector('b);
// Latent-only flow: runs later; channels and escaping mode are explicit
let schedule: pure fn!(
callback: latent (pure fn!() -> Unit) @ global,
) -> Subscription;
| Flow | .shc spelling | Meaning |
|---|---|---|
Ignored | f: ignored fn(...) -> T | No invocation, forwarding, retention, or escape. |
Current | f: fn(...) -> T | Default; channels join this call, no escape. |
Latent | f: latent (pure fn!(...) -> T) @ global | Latent-only; explicit absolute channels, no current invocation. |
CurrentAndLatent | f: (fn!(...) -> T, eff Logger) @ global | May invoke now and retain; current calls join channels. |
An escaping mode permits a latent destination but does not remove the default current destination. latent explicitly removes it. @ once and @ required refine invocation count independently. Effects are a may-happen summary (Vector.map([], f) still carries f's possible effects). ignored suppresses only callable flow; channels incurred while evaluating the callback argument expression still behave normally.
At every application, the checker joins the callee's intrinsic Call and each relative callback argument into the ambient ChannelMode = { exn: ExceptionRow, eff: EffectRow }. This relation is visible in .shc; it is never recovered by reading a hidden implementation body.
Contextual Code Is Not A Callback
let code = #code(Postgres.query("select 1"));
let rows = #insert(transform(code));
#code records obligations without performing them; #insert rechecks and contributes services/errors to the insertion site. Code may be passed around at comptime; scope prevents escaping required bindings/providers. Erased before runtime IR. First-slice Code does not expose generic channel rows.
Interfaces And Providers
effect service Email {
default_from: EmailAddress;
send_reset: pure fn(email: EmailAddress, token: ResetToken) -> Unit,
exn [ :smtp(SmtpError) ];
}
let service SmtpEmail: Email = {
type t = {
client: Smtp.Client;
from: EmailAddress;
}
init = fn() -> t,
exn [ :secret(...) | :smtp(...) ],
eff Secrets
{
let smtp_url = Secrets.require("SMTP_URL");
return { client = Smtp.connect(smtp_url); from = ...; };
};
drop = fn(self: t) -> Unit {
Smtp.Client.close(self.client);
};
default_from = fn(self: t) -> EmailAddress {
return self.from;
};
send_reset = fn(self: t, email: EmailAddress, token: ResetToken)
-> Unit,
exn [ :smtp(SmtpError) ]
{
...
};
};
Interfaces name constants and operations — not provider state, not product types. Nominal: same shapes ≠ same capability. Builtin Provider(Effect) covers ad hoc / request-scoped providers (tests, CurrentUser, route steps); install with the same provide.
Calls enter eff at the call site — no routine use Email:
Email.send_reset(user.email, token);
// inferred eff [ Postgres | Email | Auth ] (ambient Clock hidden)
Discharge: provide
provide {
Config = EnvConfig;
Postgres = MainPostgres;
Email = SmtpEmail;
} {
Server.run(routes);
}
The RHS may be a named let service provider or an expression returning Service.Provider(Service):
provide {
AppDb = Sqlite.file(AppDb, ".state/app.sqlite");
} {
run_app();
}
Provider expressions are evaluated once for the scope, then their checked Provider recipe follows the same init/drop lifecycle as a named provider. The RHS is checked against the left-hand service before that service is removed from the body's effect row. Control and command bindings use their corresponding handler/runtime kinds; one block may mix kinds.
Binding Order (locked)
Services: unordered set; topo-sorted from
init … eff …; cycles rejected;dropreverse topo. Textual position irrelevant.Control: ordered nesting stack (top = outer). Observable for dispatch and
return-clause transforms.Commands: like services (usually once at root).
Mixed block → nest of single-effect installs; result type = outermost control
return(identity for services/commands).
Lifecycle And Failure
Init once per scope (uninterruptible); init failure drops already-started providers and skips the rest. drop runs however the scope ends, outside its own binding, and a failing drop joins the scope's flat Cause. drop = fn(self: t, exit: ScopeExit('error)) sees how the body ended (Resource scopes); drop(self) ignores it.
Test Provider Factories
suite "forgot password" {
providing Clock = Test.Service.per_test((_test: Test.Case) =>
module: Service.Provider(Clock) #{
let service Provider: Clock = FixedClock("2026-05-27T00:00:00Z");
}
);
providing Email = Test.Service.per_test((_test: Test.Case) =>
module: Service.Provider(Email) #{
let service Provider: Email = RecordingEmail;
}
);
test "sends reset email" {
send_reset(...);
expect RecordingEmail.sent_count() == 1;
}
}
Test.Service.per_test and per_suite are ordinary standard-library functions, not service-specific compiler rules. Each takes a pure factory over an ordinary Test.Case / Test.Suite value and returns a named or anonymous provider module. The factory constructs the provider recipe; its init acquires the resource and outcome-aware drop releases it. per_test invokes the factory for every selected attempt; per_suite invokes it once per selected suite instance. Source order remains provider dependency order and teardown reverses it.
Runtime provide { ... } { ... } remains available for narrow overrides inside one test body. Test-level providing is declarative so ship test can discover, filter, preflight, and schedule service lifecycles before execution.
Multiple Instances
Multiplicity is nominal or keyed — never positional/
mask.Ambiguity is a compile error, not innermost-wins.
| Stage | When | Shape |
|---|---|---|
| 0 | Different authority | Distinct nominal effects (PrimaryDb vs ReplicaDb) |
| 1 | Same interface, known family | Keyed labels: Database(:primary), Cache(User) |
| 2 | Unbounded runtime families | First-class scoped handles — deferred (../questions/042-first-class-effect-instances.md) |
One in scope → bare call. Two+ → disambiguate. Nested same key → intentional override. Duplicate key in one block → error.
Transactions
Transactions owns the design. Read/write split uses Stage-0 distinct effects.
Ambient Vs Explicit
Ambient (always available, hidden from ordinary eff display, overrideable): Clock, Console, Random, Logger, Config, Runtime.
Security splits stay explicit: CryptoRandom, AuditLog, Secrets, Process/Shell vs ambient peers.
Explicit (appear in eff): Postgres, FileSystem, HttpClient, Email, Queue, ObjectStorage, DurableStorage, PaymentGateway, Secrets, CryptoRandom, Process, Shell, Browser, Clipboard, ActorSystem, …
Rule: outside process, credentials, irreversible, or deployment-varying → explicit. Target-specific (Browser) → diagnostics on other backends, not polyfills. Native servers install typed FileSystem, Secrets, SQLite, and HTTP providers; app code gets no ambient host module access. HttpClient uses portable Http.Request/Response — 18-http.md. Entry points still provide the module-qualified interpreter (FileSystem.LocalFileSystem, HttpClient.NativeHttpClient); the target does not make those services ambient.
Colorless I/O
Service operations return values. They do not return Async.Task, and they do not use Result-shaped Outcome wrappers. A provider may block in native code or suspend in a Browser host adapter without changing the source signature or adding Async to eff.
effect service FileSystem {
read_string: pure fn(root: String, path: String) -> String, exn [ :fs(FileSystem.Error) ];
}
let load = fn(root: String, path: String) -> String, eff FileSystem {
return FileSystem.read_string(root, path);
}
provide { FileSystem = FileSystem.LocalFileSystem; } {
let text = load(".", "README.md");
}
Do not split FileSystem.Sync / FileSystem.Async, stamp async fn on I/O, or leak host promises into .shc return types. Host yielding is a backend / provider fact — 167-js-async-provider-abi.md.
Source eff Vs Host Operational Effects
Source eff tracks provide-discharged requirements only. pure fn means eff = [] — not "no host I/O" and not referential transparency.
Free #builtin host APIs that are not service operations (Password hashing, Terminal secret reads, dialect Connection connect/query/close) seal the effect axis with pure and keep operational effectfulness on the provider:
#builtincomptime = Effectfulblocks pure-comptime evaluationFuture backend claim descriptors own allocation / blocking / re-entry (provider semantic ABI)
Do not invent
effect service Password/Terminal, or stamp free Connection APIs with dialecteff Postgres/eff Sqlite, just to satisfy.shcsealing. Dialect services own providerexecuteonly; Connection handles carry authority as values (database).Contrast settled service-backed hosts:
effect service Console { ... } // ambient; overrideable
let print: fn!(...) -> Unit, eff Console = #builtin(...);
effect service Postgres {
execute: pure fn(...) -> Vector('row), exn [ :db(String) ];
}
// Free Connection primitives — empty source eff; host ops via provider facts
let connect: pure fn(url: String) -> Connection, exn [ :db(String) ] = #builtin(
id = "postgres_connect",
runtime = %{ Native },
comptime = Effectful,
);
let hash: pure fn(password: String) -> String, exn [ :password(String) ] = #builtin(
id = "password_hash",
runtime = %{ Native },
comptime = Effectful,
);
Declare a new effect service only when provide / override is a real requirement (tests, sandbox, alternate backends). Fold into Crypto / Process only after those services exist with a provider story — not as syntax labels.
Implementation Model
Type-system feature; default impl = static hidden capability arguments (not broad runtime lookup). Service ops → plain calls. Control ops → one-shot delimited continuations (OxCaml-effects model — research/questions 122–128).
Frontend Program Channels
Browser TEA/Elm programs use compiler-created command / outcome open-row aliases — finite structural data the runtime or parent interprets:
type command = [ | :fetch_user(UserId) ]
type outcome = [ | :saved(User) ]
let update = (model, message) => match message {
| :clicked_refresh -> {
command(:fetch_user(model.user_id));
model
}
| :saved_from_server(user) -> {
outcome(:saved(user));
{ model with user = Some(user) }
}
};
update stays model-only at source. Tests assert on reified commands and override interpretation with provide fresh — 14-browser-programs.md.
Open Follow-Ups
Inline
handler { … }/ command runtime surfaceTransaction surface details (read-only, explicit
:tx, retry placement)Scoped runtime context —
../questions/041-scoped-runtime-context.mdCleanup with suspended continuations — question
126Context polymorphism for
Codechannel rows
Related
08-errors.md, 02-type-inference.md, book/003-Functions.shp, book/005-Databases/000-init.shp, 14-resource-cleanup.md, 01-syntax.md, ../research/oxcaml-effects.md.