Date: 2026-10-12
Belongs to: architecture.md, decisions D2 and D3
The reasoning behind architecture choices that need more room than a row in the decisions table.
“Plain Kotlin” means ordinary Kotlin libraries compiled for the JVM. Android apps run JVM bytecode, so the command-line tool and the app share them directly.
Pros:
Cons:
java.time or java.util.regex. That is fine for now, but each one is work if Multiplatform comes later.Pros:
Cons:
expect/actual declarations, slower builds, rougher tooling.Plain Kotlin, with :core kept clean enough that a switch later is a build change, not a rewrite. The one thing that would change this is wanting the browser simulator to run the real pipeline.
iOS may follow once the Android prototype is good. Kotlin Multiplatform supports iOS (shared Kotlin compiles to a native framework), so the question is what a later switch from plain Kotlin costs. The build side is mechanical: the multiplatform plugin, src/commonMain, the targets; about a day. The rest is every use of a JVM-only API:
| JVM-only | Where it would show up | Multiplatform replacement |
|---|---|---|
java.text.Normalizer |
Accent folding in the normalizer’s lookup key | A small platform implementation, or a library |
java.security.MessageDigest |
The evidence fingerprint | A multiplatform hashing library |
java.time |
Retrieval times, cache lifetimes | kotlin.time, and kotlinx-datetime for calendar dates |
| OkHttp engine | :net |
Ktor’s Darwin engine on iOS; configuration only |
| SQLite JDBC driver | :cli |
SQLDelight’s native driver; :store already takes an injected driver |
| JUnit 5 and MockK | Every test | kotlin.test and hand-written fakes |
With the rules in place from the start (see Multiplatform readiness in the architecture), the switch is about a week: the build, two or three small platform implementations, and a test run on the iOS target. Without them it grows with the JVM-only usage, likely one to three weeks, most of it in tests.
Sharing the pipeline does not make an iOS app. iOS does not let an app read what another app is playing: MPNowPlayingInfoCenter lets an app publish its own now-playing information, not read anyone else’s, and the private MediaRemote framework has never been available to App Store apps. On macOS, where some utilities used it, Apple restricted it to entitled system processes in macOS 15.4, which suggests the direction. A change looks unlikely; the one plausible lever is EU interoperability regulation, which so far targets connected devices, not this. An iOS version would need another input:
| Input | Gives | Catch |
|---|---|---|
Spotify Web API, currently-playing |
The current Spotify track and its playlist | Spotify only; needs the Spotify account, optional on Android; five users per developer app |
| Apple MusicKit | The current Apple Music track | Apple Music only |
| ShazamKit | Recognizes the song by listening | Uses the microphone, against the “metadata only, never audio” decision |
| Manual search | A track the user picks | No “now playing” |
So an iOS version is a different product shape, decided when it comes up; the shared pipeline is what carries over.
Plain Kotlin, kept Multiplatform-ready (owner, 2026-10-12).
The app needs two calls per provider: stream a completion, and list the available models.
Pros:
:net). Rate limits, timeouts, the logger that hides keys, and the network rule are enforced in one place.Cons:
Pros:
Cons:
Small clients. The surface used is small and stable, and keeping network access in :net is one of the design’s structural constraints. If one provider later needs a feature that is painful to hand-roll, adding its SDK just for that is a contained exception.
Either way the switch stays inside :ai: the pipeline sees only AiProvider (stream and models), so a switch rewrites one implementation per provider and nothing else. The SDK side applies to Anthropic, OpenAI and Google; Ollama has no official JVM SDK and stays a small client either way.
| Switch | Code, per provider | What else it costs |
|---|---|---|
| Small clients to an SDK | About 1 to 2 days: implement AiProvider on the SDK, map its stream events and errors |
The SDK’s own HTTP stack bypasses :net, so rate limits, timeouts and key redaction are configured again inside it, and checkModuleGraph needs an exception. Its dependencies go through the allowlist and the licence check. Replay tests need a mock server instead of MockEngine. The official SDKs are JVM libraries, which breaks the Multiplatform readiness of D2 |
| An SDK to small clients | About 1 to 3 days: request and response models, the streaming parser, error mapping, recorded streams for the tests | Nothing; the dependency and the exceptions go away |
The code cost is similar both ways. The difference is that small clients keep the network boundary and Multiplatform readiness by construction, while each SDK has to be patched around both.
Small clients (owner, 2026-10-12). An SDK may still be adopted for one provider, if a feature it offers is painful to hand-roll, as a contained exception: confined to :ai, with its own checkModuleGraph and allowlist entries. After a Multiplatform switch it would move to the JVM and Android source sets, and that provider would be unavailable on iOS.