Effects map¶
A layer-by-layer map of every module's actual purity/effect status, done as part of a Scala 3/ ergonomics review (see git history around this file's addition). The goal isn't "everything should be maximally effect-tracked"; it's finding the places where the type signature lies about what the function actually does, since those are the ones worth fixing regardless of how much of the rest gets migrated to richer Kyo effects.
How to read the table¶
- Pure: deterministic, no I/O, referentially transparent. Safe to call anywhere, test without a runtime.
< Sync: Kyo-tracked, genuinely blocking I/O. The type signature is honest: a caller sees< Syncand knows this does real work.- Unsafe boundary: deliberately escapes Kyo's effect tracking (
Sync.Unsafe.evalOrThrow,AllowUnsafe), for a specific, documented, contained reason. - Hidden effect ⚠️: the type signature says "pure" (no effect type at all) but the function actually does something effectful (reads mutable external state, can throw on bad input). This is the category worth fixing; see §2.
| Module | Classification | Notes |
|---|---|---|
scoring/Swimability |
Pure | No I/O, fully deterministic, the reason it's the only module with real unit tests (SwimabilitySpec) |
model/Models |
Pure (data only) | Plain case classes/enums, no logic |
json/Json.scala's render/builders |
Pure | JsonValue.obj/arr/str/render — deterministic, total (never throws) |
json/Json.scala's parse |
Hidden effect ⚠️ (partial) | Throws JsonParseException on malformed input — see §2 |
http/Http.scala (all 4 methods) |
< Sync |
Honest — every caller sees < Sync and knows real network I/O happens |
beaches/BeachFinder |
< Sync |
Composed from Http, correctly propagates |
conditions/OpenMeteoClient |
< Sync |
Same |
water/WaterQualityMatcher, scoring/Swimability.waterVerdict, conditions/Tides, lore/SeaLore.pick |
Pure | MIP-0001's logic; all unit-tested |
water/ImaScWaterQualityClient.samplingPoints, knowledge/OllamaEmbedder.embed |
< Sync |
HTTP via Http |
knowledge/FileKnowledgeStore |
< Sync |
File I/O and embedding wrapped in Sync.defer/Embedder; Corpus.chunkDocument/cosine are pure |
lore/SeaLore.loadDefault |
Hidden effect ⚠️ (minor) | Classpath read with no effect type — same class as CompiledPrompt.loadFromFile above |
location/IpGeolocation.locate |
< Sync |
Same; each provider call is individually Abort.catching-wrapped so a dead provider drops out of the vote. consensus (the vote itself) is pure and unit-tested |
llm/LocalLlmClient, vision/*Client |
< Sync |
Same |
llm/CompiledPrompt.loadFromFile/loadFromString |
Hidden effect ⚠️ (I/O + partial) | loadFromFile reads a file with no effect type at all — not even < Sync. loadFromString throws on malformed JSON. See §2 |
llm/Reviewer.review |
< Sync |
Correctly tracked; the JsonValue.parse it calls internally is where a hidden partiality lives (see above) |
sightings/LocalFileSightingStore |
< Sync |
Correctly tracked, including proper try/finally resource cleanup for file handles |
observability/Telemetry |
< Sync (shallow) |
withSpan is honest about being < Sync, but see ARCHITECTURE.md §5f's own documented gap: no try/finally around the wrapped effect, so a thrown exception mid-span leaves it unclosed |
AppConfig.fromEnv |
Hidden effect ⚠️ | See §2 — the single most consequential finding here |
agent/SwimConditionsMcpServer |
Unsafe boundary (contained) | See §3 |
Recommender.traverse/traverseSingle, Main.printLines |
Pure control flow over < Sync values |
Hand-rolled recursion, not stack-safe for very large lists — a non-issue at marola's actual list sizes (a handful of beaches), worth knowing if that ever changes |
2. The real finding: AppConfig.fromEnv is a hidden effect¶
object AppConfig:
def fromEnv: AppConfig = AppConfig(
telegramBotToken = sys.env.get("MAROLA_TELEGRAM_BOT_TOKEN"),
...
)
This reads mutable external process state (environment variables can differ between calls in
principle, and definitely differ between processes/test runs) through a method with no effect type
at all. Its signature (AppConfig.fromEnv: AppConfig) looks exactly like a pure function. Called
fresh in multiple places (Main.bootstrap, SwimConditionsMcpServer.recommendationHandler calls
it per-request), each call is a real, untracked side effect.
Why this matters more than it looks like it should: every other I/O boundary in this codebase
(§ table above) correctly wears its effect on its sleeve via < Sync. AppConfig.fromEnv is the
one place that doesn't, and it's called from nearly everywhere. A reader scanning function
signatures to find "where does this touch the outside world" will miss it entirely.
The fix, if this gets migrated: thread AppConfig through Kyo's Env[AppConfig] effect instead
of calling fromEnv ad hoc:
def runRecommendation(args: Array[String]): Unit < (Async & Env[AppConfig]) =
for
config <- Env.get[AppConfig]
...
with the actual sys.env read happening exactly once, at KyoApp's boundary, via
Env.run(AppConfig.fromEnv)(bootstrap(args)). This is a real, contained refactor (touches every
function that currently takes config: AppConfig as a plain parameter, changing it to read from
Env instead), not done here, flagged as the top candidate if/when this codebase adopts more of
Kyo's effect-tracking beyond the I/O boundary.
The smaller version of the same problem: exceptions as untracked failure channels¶
JsonValue.parse, CompiledPrompt.loadFromString, LlmClient.extractContent, and several others
throw a specific exception type on bad input rather than returning Either/Abort[E]. Every call
site currently wraps the outermost effectful call in a broad Abort.catching[Throwable] (see
Main.scala's summarizeTop/analyzePhoto/reportSighting), which works, and is honest about
"something in here can fail," but loses the specific failure type. A stricter version would have
each function declare its own Abort[JsonParseException]/Abort[NoCompletionException] and let
Kyo's effect system compose the union automatically. Lower priority than the AppConfig finding
above. The current broad-catch pattern is simple and was live-verified working (see Main.scala's
own comment on why loadCompiledPrompt had to move inside the Abort.catching block after a real
bug), but worth naming as the same class of gap.
3. The one deliberate unsafe boundary: SwimConditionsMcpServer¶
private given unsafe: AllowUnsafe = AllowUnsafe.embrace.danger
private def runSync[A](effect: A < Sync): A = Sync.Unsafe.evalOrThrow(effect)
This is Kyo's own documented escape hatch, used for exactly the reason it exists: the MCP Java
SDK's tool-handler API (BiFunction<Exchange, CallToolRequest, CallToolResult>) is a plain
synchronous Java callback, not a Kyo-aware one, so there's no way to hand it a < Sync value
without unwrapping it first. This is the correct place for AllowUnsafe: a foreign-callback
boundary, contained to one file, with the reason documented right there in the code and again in
ARCHITECTURE.md §5c. Not a finding to fix; a pattern to recognize as legitimate when it shows up
elsewhere for the same reason (a future Telegram SDK's callback API, if it turns out to have the
same shape).
4. Resource lifecycle — not yet a problem, but worth naming¶
A client built on first use and never closed is a non-issue for a CLI process that runs once and
exits: the OS reclaims everything on
exit. It becomes a real question once marola runs as a long-lived service (the Telegram bot, Phase
1) that might reconfigure or reconnect: Kyo's kyo.Scope effect (acquire/release, seen in the
kyo-core dependency already) is the natural fit for that later, not needed now.
5. Net assessment¶
Nothing here is unsafe in the memory/concurrency sense. This is a Scala/JVM codebase with no raw
mutation escaping module boundaries (the one var in Json.scala's recursive-descent parser is
fully encapsulated in a private class). The gap is specifically the FP-purity sense: one function
(AppConfig.fromEnv) whose type signature hides that it does I/O, and a handful of functions that
can throw without saying so in their type. Both are real, both are fixable with Kyo's own Env/
Abort effects, and neither is urgent enough to have blocked shipping the features this review
accompanied. Captured here as the concrete next step for whoever picks up
FUTURE-WORK.md §2's kyo-http/kyo-schema migration, since that's the natural moment to also
tighten these two effect boundaries.