Status: Proposal, for discussion
Date: 2026-10-12
Builds on: design.md and the simulator
Tracking: #3
This document turns the design into a buildable structure: modules and their boundaries, the runtime data flow, the technology choices, how the structural constraints are enforced, testing and CI. It starts with a review of the design and the simulator, because several findings change the architecture. The decisions it needed are collected at the end, as made by the owner.
The design is sound in its core idea: deterministic code decides what is true (resolution, matching, citation checks), and the model only narrates. The typed resolution outcomes, the separation of none from source_off, the scope on every evidence line, and the mechanical grounding check are what make the product trustworthy, and the architecture below is organized around keeping them in code the model never touches.
The findings below are the places where the design is incomplete or internally inconsistent, or where the implementation needs something the design does not yet specify.
The simulator is a prototype of the experience: it shows how the app behaves as if every part worked, and stands in for the parts that do not exist yet with frozen data. Some of its code is the app’s real logic and ports directly; for the rest, the implementation starts from the design alone:
| Component | In the simulator | For the implementation |
|---|---|---|
| Normalizer | Real logic (normalize) |
Port |
| Prompts, grounding check | Real logic (promptInsight and the others, groundCheck), live against Claude when run as a claude.ai artifact |
Port |
| Debounce, cancellation | Real logic (onMetadataChanged, runPipeline) |
Port, as coroutines |
| Caching rules | Real logic (fetchSource for retrieval; write and writeJson, keyed with srcSig, for insights and cards) |
Port, with the synthesis key extended per R2 |
| Source adapters (Wikipedia, Last.fm, web search) | Stood in for: frozen snapshots behind simulated latency (querySource, fetchSource over facts-data) |
Build |
| Entity resolution | Stood in for: each fixture record carries its outcome (song_article, mismatch, …) |
Build |
| MusicBrainz | Stood in for: shown as blocked |
Build |
| Multi-artist resolution | Stood in for: the primary artist’s data only | Build |
| Evidence extraction | Stood in for: fact lines written in advance, in facts-data |
Build |
| Discogs | Not shown | Build (Phase 3) |
The components to build are the ones the design calls the hard ones, so the architecture puts them in plain Kotlin behind interfaces, where Phase 0.5 can iterate on them in a command-line tool, on the fixture playlist, before any Android code depends on them. The simulator’s frozen data then serves Phase 0.5’s tests (see Testing): its resolution outcomes as expected outcomes, and its fact lines as reference facts that extraction should find, though not word for word.
| # | Finding | Effect on the architecture |
|---|---|---|
| R1 | Nobody extracts the evidence lines. The design says each line is “one claim, in the adapter’s own words, never copied prose”, but an adapter is deterministic code. It can template structured data (MusicBrainz, Wikidata, Last.fm counts and tags) but it cannot paraphrase a Wikipedia article or a web page. The simulator’s lines were written in advance, standing in for this step. | Evidence extraction becomes an explicit stage with a stated method per source. See D1. |
| R2 | The synthesis cache key can serve stale citations. It is “lookup key, enabled-source set, provider and model”. When the retrieval cache expires and refetches, evidence ids can point at different lines while the cached insight still cites the old ones. | The synthesis key includes a fingerprint of the evidence set. A cached insight is reused only for the exact evidence it was written from. |
| R3 | The latency goal and the MusicBrainz rate limit conflict. “Within a couple of seconds” against one request per second, with a recording search, a recording lookup and one lookup per credited artist before Wikipedia can be reached safely through Wikidata. A cold track with two artists is about four seconds of MusicBrainz alone. | Progressive rendering: player metadata immediately, each source’s result as it lands, the insight last. Artist resolutions are cached long-term, so the cost falls on first encounters. A per-host rate limiter is part of the network layer. See D4. |
| R4 | The privacy statement is narrower than the traffic. Matching by duration (“duration tolerance”) sends the duration if it goes into the MusicBrainz query, and every request reveals the phone’s IP address to that service. | Duration filtering happens on the phone, after the search. The first-run text names the IP address. |
| R5 | “User-driven changes write” needs a signal the session does not give. A skip pressed inside Spotify and a track ending on its own both arrive as onMetadataChanged. Only Sleevesight’s own transport buttons are known to be user actions. |
Measured in Phase 0 (design.md, Track changes): the time left at the change, and for Spotify the playback states before it, under a Crossfade setting. See D5. |
| R6 | Genre labels depend on one source with a user key. Last.fm needs the user’s own API key; with it missing or off, no track gets genres, and 30 of 78 tracks got none even with it. MusicBrainz has genre data per artist, release group and recording, keyless. | MusicBrainz tags are not used to fill Last.fm’s gaps: they filled 3 of 30 at the Phase 0.5 gate (D12). Whether they are the keyless labels when Last.fm is off, which they would be on 29 of 78 tracks, is decided with the genre labels in Phase 2 (#95), if their licence allows it (the licence findings are in MusicBrainz tags and genres). |
| R7 | Background behaviour is unspecified. The notification listener runs whenever access is granted, app open or not. The design does not say whether lookups run while nobody is looking. | Session history is recorded always, on the phone only. Retrieval and synthesis run only while the app is visible. See D6. |
| R8 | Sideloaded installs cannot easily grant notification access. Since Android 13, “restricted settings” block notification-listener access for apps installed from outside an app store, until the user unlocks it in the app’s info screen. | Onboarding detects the block and explains the unlock. With distribution through Google Play (D7), this affects only development installs and any channel outside Play. |
| R9 | A local model through Ollama is served over plain HTTP by default, which Android blocks, and an arbitrary LAN address cannot be allowlisted in a network security config. A home HTTPS proxy often uses a self-signed or private-CA certificate, which Android does not trust either. | How the app reaches Ollama, and which certificates it accepts; see D8. |
| R10 | simulator.html carries the artifact runtime. Line 1 is a <script> tag loading the claude.ai artifact frame runtime from claudeusercontent.com, with a signed URL, saved from the rendered page. Outside claude.ai window.claude is absent, so the README’s GitHub Pages link would show the evidence-only fallback, never live synthesis; and Pages does not serve private repositories on a free plan. |
Not architecture, but recorded here; a separate cleanup issue. |
| R11 | design.md still said “License: open source (to be chosen)”; it is GPL-3.0 since #2. |
Fixed by #6, with the README. |
In order of weight:
minSdk are not settings. :app reads the settings from DataStore into a plain Settings type defined in :core, which also holds the defaults, so :cli shares them; :core and :store read it directly, and :ai uses it to configure its Ollama client in :net. :app never touches :net.flowchart TD
app[":app (Android)"] --> core
app --> sources
app --> ai
app --> store
cli[":cli (JVM)"] --> core
cli --> sources
cli --> ai
cli --> store
cli --> net
sources[":sources (JVM)"] --> core
sources --> net
ai[":ai (JVM)"] --> core
ai --> net
store[":store (JVM)"] --> core
net[":net (JVM)"]
core[":core (JVM)"]
| Module | Kind | Contents | May depend on |
|---|---|---|---|
:core |
Kotlin JVM | Domain model; normalizer; resolution logic (scoring, matching, outcomes) over client interfaces; evidence model and ids; prompt builders; grounding check; genre vocabulary; the pipeline orchestration as coroutines and flows; the diagnostics event type | Kotlin stdlib, coroutines, kotlinx.serialization. No network, no Android |
:net |
Kotlin JVM | The one HTTP client: Ktor with the OkHttp engine, per-host rate limiting, the User-Agent, timeouts, retries, and a logger that redacts keys and tokens | Ktor, OkHttp |
:sources |
Kotlin JVM | One adapter per source (MusicBrainz, Wikipedia and Wikidata, Last.fm, Discogs, web search), each implementing the :core client interfaces and SourceAdapter, with its evidence extraction. Also the other outbound clients that are not sources: the Spotify Web API client (Phase 3) and an album-art fetcher for http art URIs |
:core, :net |
:ai |
Kotlin JVM | AiProvider implementations: Anthropic, OpenAI, Google, Ollama. Streaming, model listing, error mapping |
:core, :net |
:store |
Kotlin JVM | Retrieval cache, synthesis cache, artist-resolution cache, session history, track-change records (D15). SQLDelight schema and queries over an injected SqlDriver: :cli supplies the SQLite JDBC driver, :app the Android driver (an AAR, which a JVM module cannot depend on) |
:core, SQLDelight runtime |
:cli |
Kotlin JVM application | Phase 0.5: runs the pipeline over a playlist fixture; writes the coverage report and the insights; records HTTP responses for tests | :core, :sources, :ai, :store, :net (to create the process’s one NetClient and register the adapters’ host policies, #45), SQLDelight JDBC driver |
:app |
Android application | Session layer (NotificationListenerService, MediaSessionManager); Compose UI; settings (DataStore); secrets (Keystore); wiring; diagnostics screen |
:core, :sources, :ai, :store, SQLDelight Android driver |
The boundaries do the enforcing. :core cannot reach the network because it has no HTTP dependency; only :sources and :ai see :net, besides :cli, which creates the one client but makes no request of its own; and :app gets network results only through them. The checks under Structural constraints make that a build failure rather than a convention.
sequenceDiagram
participant P as Player
participant S as Session layer
participant Pl as Pipeline
participant Src as Source adapters
participant AI as AI provider
participant UI as UI
P->>S: onMetadataChanged
S->>UI: NowPlaying (immediately)
S->>Pl: NowPlaying (debounced 650 ms, latest wins)
Pl->>Pl: normalize
Pl->>Src: look up by name, all sources in parallel (MusicBrainz resolves alongside)
Src-->>Pl: typed outcome + evidence, per source as it lands
Pl->>Pl: verify each match against the resolved ids (D4)
Pl-->>UI: TrackState updates
UI->>Pl: Write (tap, or the auto-write setting, D5)
Pl->>AI: prompt (evidence only)
AI-->>Pl: streamed text
Pl->>Pl: grounding check
Pl-->>UI: Written insight + check counts
StateFlow<NowPlaying?> and StateFlow<PlaybackInfo>.nowPlaying.filterNotNull().debounce(650.milliseconds).map(::normalize).distinctUntilChangedBy { runKey }.collectLatest { run(it) }: normalization comes first, because what a run depends on is the NormalizedTrack (its lookup key, credited artists and work), plus the album (the P line) and the length (resolution chooses the recording by it); a change that alters none of them, such as new art or the same metadata sent again, is not run again, and one that alters any is (#44 review: lookupKey alone kept the previous track’s release and artists). collectLatest cancels a superseded track’s retrieval and synthesis, the coroutine version of the simulator’s AbortController and app.gen. A null (no active session) is filtered out, so it neither cancels the current track’s run nor clears its state; the UI reads nowPlaying directly to show the idle state.run, each in its own child coroutine, each publishing its result as soon as it lands.The UI renders one immutable state per track:
data class TrackState(
val nowPlaying: NowPlaying,
val normalized: NormalizedTrack?,
val sources: Map<SourceId, SourceState>, // Pending | Done(outcome, evidence, latency, cached) | Failed(error)
val evidence: EvidenceSet?, // ids assigned, fingerprinted
val genres: List<GenreLabel>,
val similar: List<SimilarArtist>,
val insight: InsightState, // Idle(canWrite) | Writing(partial) | Written(GroundedText) | NoEvidence | Failed(reason)
)
How the orchestration is made (#44, Pipeline in :core), where the sections above leave room:
Pipeline.follow(changes: Flow<TrackChange?>) is the app’s loop, as under Concurrency; a TrackChange carries the session layer’s verdict on why the track changed (ChangeCause: an action in Sleevesight, a skip in the player, or a natural end), which AutoWrite turns into D5’s decision, together with whether the user has acted since the app opened. Skip detection itself stays in the session layer. Pipeline.run(nowPlaying, write) runs one track and returns its final state, for the CLI. write() is the tap on Write; userActed() the first action. Settings is read once per track.resolution (MusicBrainz’s, once made); similar waits for the sounds-like card (Phase 2), so it is not there yet. SourceState.Done holds the adapter’s whole SourceResult and whether it came from the cache; Failed holds the exception’s type only, for an adapter that threw (a bug: the network is unreachable). A switched-off source is Done with source_off. The evidence and genre labels are assembled once every source has landed.:core, PipelineCache, which :store implements (StorePipelineCache, running the blocking database calls in an injected I/O context): :core cannot see :store. A failing cache is logged and treated as a miss.P line, worded as the simulator words it (“This recording is on the release “…”.”), with no year: the session does not report one. The other lines are the enabled sources’ verified lines, in the adapters’ order, so the ids do not depend on the order the sources land in. Genre labels come with the source’s result (SourceResult.genres, Last.fm’s), at most five, once each.NO_EVIDENCE. A card is served from the synthesis cache when its full key matches (kind, the track’s retrieval key, fingerprint, InsightPrompt.VERSION, the style’s hash, provider, model, model-knowledge switch, reasoning effort); CardKind names the kinds by fixed keys. A reply is kept only when it ended normally, not when it was cut off at the token limit (shown, marked incomplete) or declined. The request carries the reasoning effort (below).PipelineEvents go to a SharedFlow that never suspends the pipeline: a slow collector loses the oldest of the last 256.Settings.aiEffort, low by default; driver 6): how much the model may reason before it writes, mapped by each provider to its own control (Anthropic’s output_config.effort). Claude Haiku 5.5 reasons by default at medium effort; an insight narrates a few lines and is waited for, and the grounding check, not the model’s deliberation, keeps it honest, so low is the default. Thinking is not switched off: not every model allows it. The effort is sent only to models known to take it (AnthropicProvider.takesEffort, a list kept with the provider, since the model list carries no capabilities): older models answer it with an error, and get the request without it.:app)SleevesightListenerService : NotificationListenerService is declared only to obtain the session permission; it ignores notifications themselves.SessionRepository registers addOnActiveSessionsChangedListener and a MediaController.Callback on the active session of each followed player, and maps metadata to NowPlaying (title, artists string, album, duration, art bitmap, package, media id when present, active queue item when published). Events from players not followed are dropped and logged as ignored.design.md Track changes): the media id when the player sends one; else the active queue item where the player publishes one (an item of −1 is no item; when either side has no item, title and artists decide); else title and artists. A known limit: a song repeated right away (repeat-one, or the same song queued twice) is the same track, unless the player gives no media id but separate queue items, so it gets no new skip decision, no new entry in the session history, and no new lookup. A new length or new art for the same track is an update, not a change, and the time left is computed from the latest length. The pipeline’s lookup key (normalized artist and title) is unaffected.PlaybackState.actions advertises them.sessionActivity for the player’s own screen; spotify: URIs when an id is known; spotify:search: otherwise, logged as a fallback.getActiveSessions on start.getQueue() and getQueueTitle(), the track list uses it; Spotify does (measured in Phase 0), as well as naming the playlist in its metadata. Spotify’s Device Broadcast intents are not adopted: Phase 0 showed the session carries everything they do and more.none, and how much of the UI to hide stays the design’s open question on per-player behaviour.:store locally, whether or not the app is visible.:core)A port of the simulator’s normalize, with the design’s table and every fixture title as unit tests, and the deviations listed in Normalizer’s KDoc. One of them is the lookup key (#72): it keeps letters and digits in any script, and +, where the simulator kept ASCII letters and digits only, so non-Latin tracks shared a key (every Japanese, Cyrillic or Greek track keyed as |) and, as the cache key, would get one another’s evidence. Accents are folded away only on Latin and Greek letters; elsewhere a combining mark stays with its letter (the breve of Cyrillic й, Japanese voicing marks, Indic vowel signs), so different words do not merge. Symbols and emoji are dropped in either plane (supplementary ones by block, which the portable character classes do not classify); variation selectors, tags and enclosing marks are dropped without separating the letters around them. A title, artist or work of symbols only, which would fold to nothing, keys as its own characters (without variation selectors, tags and enclosing marks), so 😀 and 😢 differ and ★ keys as ★ with a variation selector does. Normalizer.foldForKey applies the same folding to the other parts of a cache key; the test is by character class, not a \p{L} regex, which is not portable. Every fixture track keeps the simulator’s key. Accent folding (Unicode NFKD) sits behind a small interface, since java.text.Normalizer is JVM-only (D2). Output: NormalizedTrack(title, artists, primary, from, variant, notes, lookupKey).
:core logic, :sources clients):core holds the scoring and decisions; :sources provides MusicBrainzClient and WikidataClient. Per D4, resolution runs alongside the lookups by name rather than before them: each source’s match is verified against the resolved ids when they land. A Wikipedia article can also be verified without MusicBrainz, through its own Wikidata item (step 5). Matches from the other sources stay unverified (shown, not cited) if MusicBrainz finds nothing.
none; a near miss with a conflicting artist is rejected(reason).P evidence). Both are kept; which one the UI shows is still the design’s open question on release-date semantics.original_song.wikibase_item). Either way, the article is accepted only if Wikidata confirms it: instance of song, single or musical work, with a performer (P175) matching a credited artist; otherwise rejected. The performer is matched by its MusicBrainz artist ID (P434) when artist resolution has one, and by normalized name otherwise. This is the check that rejects the wrong Reem.How steps 1 to 4 are made (#36), where the steps leave room:
ResolutionThresholds.TITLE_SIMILARITY), its length is within DURATION_TOLERANCE of the player’s (a missing length on either side does not count against it), and it credits at least one of the track’s artists: by name, or as a band one of them leads, in the usual forms (X and His …, X and Her …, X & the …, X with …, the leader’s name whole; #77: the 1962 “Misirlou” is credited to Dick Dale and His Del-Tones, the player says Dick Dale). The wording alone is not enough (“Echo” is not Echo & the Bunnymen): the leader must appear on the recording, in a performer relationship of the band’s matching recordings (its preferred three are fetched), and every id of a performer of that name found there is the artist’s for the band’s candidates, unless that recording also credits the same id by name: beside a credit to “Alex Smith”, that Alex Smith’s vocal does not show who leads “Alex Smith and His Orchestra”, so a mixed credit is confirmed only by another of the band’s recordings. A band’s own MusicBrainz id is never its leader’s; when the chosen recording names the leader neither in its credit nor in its relationships, the confirmed id stands. With the player’s length, a candidate whose length is known and fits is preferred to any with no length, which may be a demo, a bootleg or a live take. Of the accepted, the earliest released is chosen, so a remaster’s recording does not stand in for the original’s. A title and length match with no credited artist in common is rejected; so is a credited name that the accepted candidates, after the length has narrowed them, give to more than one MusicBrainz artist. Every matching credit counts (two artists of one name on one recording collide), and a band credit counts as every leader of that name found on its recordings, or, with none found within the three tried, as an unknown artist, since the cap and MusicBrainz’s many recordings without relationships prove nothing: a namesake’s solo recording and the real leader’s band, confirmed or not, or two bands led by different artists of that name, collide. A band alone, unconfirmed, credits no one and is a near miss.How step 5 is made (#37, IdentityCheck), where the step leaves room:
P31 classes (Wd.SONG_CLASSES), matched directly, without following subclasses. A disambiguation page is rejected as one.The Wikipedia adapter (#38, WikipediaSource), where the design leaves room:
Title (Artist song), Title (song), Title, also in Wikipedia’s casing and without a leading The) and each credited artist’s name, following Wikipedia’s redirects; no full-text search, whose results for small acts are noise. Then the items MusicBrainz links, then the articles found by name, each through the identity check; the first confirmed song wins. An artist’s article is reached through its MusicBrainz id (P434), and one found by the artist’s name is checked against it, so the wrong Reem is a rejection." opens or closes one in turn, and a piece left with one open at the end of its paragraph is dropped; a straight " or a closing curly ” directly after a digit (any Unicode number, 7½) may be an inch mark, a 7" single, which would throw the pairing off, so a paragraph holding one is dropped whole: the cost, a known limit, is that such paragraphs are lost, quoted numeric titles such as "1999" among them); each linking its article (its title percent-encoded); then the item’s Wikidata lines.SourceResult.rejections, each credited artist as its own subject. The song’s candidates are alternatives for one subject, so theirs go in only when no song is confirmed: a confirmed Sundown (Gordon Lightfoot song) does not list the rejected disambiguation page Sundown.Thresholds and tolerances are constants tuned in Phase 0.5 against the fixture’s known cases. They decide whether a match is correct, so they stay fixed rather than becoming user settings (driver 6). The gate run (#46) left them unchanged and found a reason to raise the search size, since raised to 100 (#78); the evidence is in the design’s Phase 0.5.
:sources)Wikidata’s lines carry a source of their own, WIKIDATA with the tag WD, apart from Wikipedia’s W: they are templated from structured data and cached for their own lifetime (D9). The user’s one Wikipedia switch covers both. The player’s metadata (P) and the model’s own knowledge (M) are pseudo-sources that retrieve nothing.
SourceAdapter as in the design, with the input D4 needs: lookup(track: NormalizedTrack, resolution: Deferred<Resolution>), so an adapter starts by name at once and awaits the resolved ids only to verify. It returns SourceResult(outcome: Outcome, evidence: List<EvidenceDraft>, latency, unverified: List<EvidenceDraft>, rejections: List<Rejection>). Evidence extraction per source (D1):
| Source | Evidence lines |
|---|---|
| MusicBrainz | Templated from structured data: first release, writers, producers, performers, work and cover relationships |
| Wikidata | Templated: genre, record label, notable awards, inception |
| Wikipedia | Selected sentences from the article’s lead, verbatim, attributed (CC BY-SA 4.0), at most a fixed number per article, filtered to those that name the entity |
| Last.fm | Templated: listeners, tags, similar artists. Bio: selected sentences, verbatim, attributed (CC BY-SA) |
| Discogs | Templated: personnel, labels, formats |
| Web search | One line per result: the provider’s snippet, verbatim, with its URL. Exa first (D11): its highlight, whitespace collapsed; at most five results, kept only when they name the title, the work it is from when the player gives one, and a credited artist (a distinctive one when there is one), and their own title does not name the artist without the song (RecordingFilter, shared by any later provider; design, Web search provider (D11)) |
A match that cannot be verified is shown, never cited (D4): SourceResult keeps its lines apart, in unverified, from the verified lines in evidence, which alone get ids and go to the model. The default of unverified is empty, so a line is cited only when an adapter puts it in evidence. A match that fails the check gives neither, and rejected(reason); each rejected candidate is also kept in rejections, with what it was looked up for and why, also when another candidate gave lines (one credited artist of several), so Sources can show it. Outcome says what the source found, not whether it was verified, and has no unverified case: a result with lines only in unverified is the unverified one, whatever its outcome.
Last.fm describes artists, so each credited artist is looked up by name (artist.getInfo and artist.getTopTags, with autocorrect=1) and judged when the resolution lands, in this order:
Jessiah, which Last.fm answers with Jesaiah, a Swedish metalcore band. So track 61 is rejected without MusicBrainz, and also when resolution finds the real Jessiah, whose id differs.Resolution.NONE), every Last.fm match is unverified at best.A page several artists share is unverified at best, even when it carries the resolved artist’s id: its listeners, tags and similar artists mix them. It is recognised from its bio, which starts “There are several artists …”, says several artists share the name, or enumerates them (a 1) or 1. at the start or after a space, a line break included, and a 2) or 2. after a space), and it gives no bio sentence. The detection deliberately over-matches, since taking a shared page for one artist’s is the unsafe failure; known limit: a single artist’s bio with chart positions (“No. 1. … No. 2.”) is taken as shared, so its match is unverified at best and its sentences are dropped. A bio sentence is quoted only whole: a summary’s unterminated or ellipsized tail is dropped. Genre labels come from the top tags weighted at least 10 of 100 (a starting value; not tuned in #46, which ran without a Last.fm key, so still open), or from artist.getInfo’s five tags when the top tags do not arrive, and only for a verified or unverified match. The adapter’s lookupArtists returns, besides the SourceResult, each credited artist’s answer, judgement and labels, for the genre and similar-artist cards.
:core)EvidenceLine as in the design. Ids are the source tag plus a position, assigned after a deterministic sort, so the same retrieval gives the same ids. The sort is by source, in a fixed order (EvidenceSet.SOURCE_ORDER), keeping each source’s own order; lines are numbered from 1 within each source, so a source’s ids do not depend on the others. Each adapter therefore emits its lines in a deterministic order, the order of its response.EvidenceSet.fingerprint is a hash over the ordered (id, scope, text) triples. It is part of the synthesis cache key (R2). The hash function sits behind a small interface (D2). It is SHA-256, in lowercase hex, over each line in order: for the id, the scope’s fixed key (song, original_song, …) and the text, the field’s UTF-8 byte length, :, and its UTF-8 bytes; then a newline. URL and retrieval time are left out, so a refetch that finds the same lines keeps the cached insight. A lone UTF-16 surrogate in a line’s text becomes U+FFFD when the line is made, so the encoding is the same on every platform. Changing the encoding, the source order, a tag or a scope key changes every fingerprint; a golden test pins them.:core, :ai):core, ported from the simulator; the prompt contracts in the design’s appendix are their specification.AiProvider in :core, implementations in :ai:
interface AiProvider {
val id: ProviderId
suspend fun models(key: Secret): ModelList // Listed(models) | Failed(kind)
fun stream(request: CompletionRequest, key: Secret): Flow<CompletionEvent> // Delta | Done(usage, stopReason) | Error(kind, retryAfter)
}
Small clients over :net (server-sent events for Anthropic, OpenAI and Google; line-delimited JSON for Ollama) rather than vendor SDKs (D3): the three official SDKs would bring three dependency trees into the allowlist, each with its own HTTP stack outside :net, and they are JVM-only, against D2. The surface used is one streaming call and one model list.
groundCheck and splitSentences, with tests from the fixtures. It deviates in two places, from the gate run (#46): (1) an abbreviation (U.S., a middle initial, Dr., feat.) directly followed by whitespace does not end a sentence, unless initials come before a word that usually opens one (It, The), so a sentence that ends in initials before another word, or in Dr. or feat., is joined to the next, and can hide it if uncited; (2) the insight prompt’s fixed sentence for a gap, Little else is documented. (GroundingCheck.GAP_SENTENCE), is a hedge; other wordings of a gap are hedges only as the simulator’s patterns allow. Its hedge detection is English-only, as the prompts are.more, start) are parsed from the JSON fields and become actions the app builds, each used only as a percent-encoded search query in the player (spotify:search:), never as a URL.The user shapes how insights are written, within limits the grounding check depends on (D14). Each prompt has two parts:
NO_EVIDENCE, plain text without markdown, and for the genre and sounds-like cards their JSON structure. It is restated after the style, so a style adds preferences but cannot override it.The setting:
Free text is the user’s own input to their own prompt, so it is not untrusted in the sense web snippets are; a style that asks for uncited claims still meets the restated contract and then the grounding check.
:core)The simulator’s vocabulary and hues, moved to a data file in :core (sleevesight/genre-vocabulary.json among its resources, read through ResourceReader; a test checks it against the simulator’s GENRE_FAMILIES), applied to Last.fm tags; MusicBrainz tags as the labels when Last.fm is off are decided in Phase 2 (D12, R6; licence: MusicBrainz tags and genres). As the simulator: tags lowercased and trimmed, kept if in the vocabulary, once each, at most five, in the source’s order; two tags that differ only in punctuation or spacing (hip-hop, hip hop) give one label. Labels are the tag in title case unless the file overrides one (UK Garage).
For the Phase 0.5 gate’s judgement (D12, #46), GenreAgreement compares the labels from an artist’s Last.fm and MusicBrainz tags as counts only, so its output can be committed or posted under the Conditions below. Fetching the MusicBrainz tags (inc=tags on the artist) belongs to MusicBrainzClient, as an evaluation-only call that nothing shown or prompted uses.
:store)| Table | Key | Lifetime |
|---|---|---|
| Retrieval | source, track (lookup key, credited artists, source work, the player’s length) | Days, per source; a user setting (D9). Web search’s results too, during development (Exa’s terms open, #80) |
| Artist resolution | normalized artist name | Long, months; a user setting (90 days) |
| Synthesis | kind, entity key, evidence fingerprint, prompt-template version, style hash (D14), provider, model, model-knowledge switch, reasoning effort | Until evicted by size |
| Session history | time | Local only, user-clearable |
| Track changes (D15) | player, time | Local only, cleared with the session history |
Offline: cached tracks work fully; uncached lookups are unreachable.
How the three caches are made (#41, Caches), where the table leaves room:
RetrievalCache.keyOf(track, duration): the lookup key, the credited artists (sorted), the work the track is from and the player’s length in whole seconds (- when it reports none, #44 review: resolution chooses the recording by its length), each folded as the lookup key is (Normalizer.foldForKey, so NFC and NFD spellings agree, and an artist or work of symbols only keys as itself), since the adapters read the artists and the work too; “Song (feat. X)” and “Song” get entries of their own. The synthesis cache’s entity key for a track is the same.SourceResult the pipeline uses (outcome, evidence and unverified lines, rejections, latency), as JSON, never the raw HTTP body. It stays small, and text the app does not show (the rest of an article, a whole bio) stays out of storage. Since a result depends on the adapter’s extraction rules as well as the response, each entry carries SourceAdapter.extractionVersion (default 1, raised when an adapter’s rules change) and the encoding’s own version, and an entry under another version is a miss, not a stale hit. Not kept: an unreachable or source_off result, and the pseudo-sources, which have no lifetime. Web search’s results are kept like any other source’s, for web search’s lifetime (D9: 1 day), and a result holding a web line lives no longer than that: during development, by the owner’s decision of 2026-10-14 (#81), reversing the #44 review’s refusal, since without the cache every repeat lookup is a paid search. Whether Exa’s terms (§4.2(a)) allow keeping its results in the app is tracked in #80 and must be settled before the app is published; the repository still holds only synthetic Exa fixtures, and published reports show web results as counts and URLs only. A result verified against a resolution that could not be made stays out too, the orchestration’s call (#44), since it would stay unverified after MusicBrainz is back.SourceAdapter.consults): the Wikipedia adapter’s result holds Wikidata’s lines, and its rejection comes from Wikidata’s identity check, so it expires with whichever is shorter. A read never returns an entry past it, measured against the settings at the time of the read, so a shortened lifetime applies at once. purgeExpired deletes such entries (and those holding a source that no longer has a lifetime, or holding a source the app no longer knows, as the adapter’s or among the sources of its lines); :cli calls it at start, and the app will on start. An entry under an older extraction version is not expired: it goes when read, replaced or evicted.AUTOINCREMENT, never reused, and a read that finds a stale entry deletes only the row it saw (by id and storage time), so it cannot delete an entry another call wrote in between. Concurrent calls are safe on a file-backed database (the JDBC driver on a file, the Android driver), not on the JDBC driver’s in-memory database, which is for single-threaded tests.resolved (the MBID), none or ambiguous (the candidates’ ids); the last two are kept so they are not asked again. Its lifetime, long, months, is a setting (Settings.artistCacheLifetime, 90 days: three MusicBrainz lifetimes, so an artist MusicBrainz adds, or a namesake that makes a name ambiguous, is picked up within a season).Settings.cacheSizeLimit, 50 MB; the reasoning is in its KDoc and the design’s Settings), counted as the UTF-8 bytes of keys and values, not the file size, and kept as running totals by triggers in the same transaction as each write, so a write does not scan the cache. An entry larger than the whole limit is not kept, and evicts nothing. Past the limit, the least recently used entries are evicted after each write and on purge: retrieval and synthesis in one order, artist entries, small and long-lived, only when no other entry is left. A read counts as a use. Caches.stats() gives entries and bytes per cache, for the diagnostics screen and the gate’s measurements (#46).kotlin.time.Clock; the database from an injected SqlDriver, with CacheDatabase.Schema created on it.:app)EncryptedSharedPreferences, which is deprecated.Secret (defined in :core, since AiProvider takes it) is a type whose toString() is redacted, so a key cannot reach a log by accident.Each insight is a paid call on the user’s key (design, Caching and offline), so when one is written is the user’s choice (D5):
Write insights automatically, under it:
| Option | Writes without a tap |
|---|---|
| On every track change (default) | Every track |
| When I skip | Tracks the user moved to: a skip in the player, or any action in Sleevesight |
| Never | Nothing; insights are written only when the user taps Write |
design.md, Track changes): Spotify, Castbox and YouTube Music report no skip state, and a natural end lands exactly at the crossfade. So a sub-setting, Crossfade (driver 6): Autodetect (the default) or a fixed value, 0 to 15 s. With a fixed value c, a change with at most c + 0.5 s left is a natural end, and for Spotify also one with the late cluster and at most 15 s left. Autodetect, for Spotify: a change with at most 15 s left is a natural end when Spotify’s late cluster of playback states is there, or when the time left matches 0 or a crossfade learned from repeating natural ends; for other players, timing alone. Changes Sleevesight caused itself need no detection. Detection errs towards “ended on its own”: that mistake costs a tap, the other an unrequested paid call. The one accepted exception: under Autodetect for Spotify, a natural end without the late cluster, before any crossfade is learned, reads as a skip.Skip detection gets better per player from data, on the device, without an LLM (D15).
:store, local only, cleared with the session history.When a track starts, the pipeline also runs for the next item in the player’s queue, so its card is ready if it plays (D13). Spotify publishes its queue with spotify:track: ids, Castbox with its episode ids, and YouTube Music its “Up next”, which starts with the playing track, so the next item is the one after the active queue item (design.md, Playlist context). A player that publishes no queue gets no preparation.
A user setting, Prepare the next track:
| Option | Runs ahead |
|---|---|
| Off | Nothing |
| Evidence | Normalization, resolution and source lookups. No paid call; it removes the slow part, MusicBrainz at one request per second (R3) |
| Evidence and insight (default) | Also writes the insight: one paid AI call per track, wasted on a skip |
:sources, so it reaches the network through :net like every other source.androidx.browser, an allowlist entry), and the redirect returns to the app through an intent filter on a sleevesight:// callback.:app)METADATA_KEY_ART_URI or METADATA_KEY_ALBUM_ART_URI instead give a URI. A content:// URI is read through the ContentResolver only if the player grants read access, which is not guaranteed (otherwise SecurityException, and no art); an http URI is fetched by a small art fetcher in :sources, since :app may not reach :net. Measured in Phase 0: Spotify’s content:// art URI is readable by another app; Castbox sends http(s) URIs. Other players are measured as they are covered.:core emits PipelineEvents (session, normalization, per-source outcome and latency, cache hits, model calls, check counts) into a SharedFlow.:app keeps the last few hundred in memory for the developer log screen. Nothing is written to disk unless the user exports it.:cli writes the same events to its report.HttpClient from :net, configured per host: MusicBrainz at one request per second with a descriptive User-Agent and contact address; the others at their documented limits. A host’s limit is in force only once registered in NetConfig.hosts: :net’s defaults hold MusicBrainz alone, so the app graph and the CLI register each adapter’s own policy there, such as ExaWebSearch.HOST_POLICY under ExaWebSearch.HOST. The rate limiters belong to the client instance, so AppGraph and :cli each create exactly one NetClient and inject it into every adapter and provider; a second instance would double every limit.Offline, RateLimited, Unauthorized, Server, Parse; TooLarge for a body over the request’s size limit; Protocol for an answer the engine refuses as breaking HTTP; InvalidRequest for a request that cannot be sent as given, such as a key with a trailing newline or a host not written in plain ASCII), which the adapters map to outcomes. No error carries an exception message, a URL or a header value. A 404 and the other statuses outside 401, 403, 429 and 5xx are responses, not errors: what they mean depends on the source, so the adapter decides.Server, except from a host whose policy says it answers 503 when its limit is exceeded (MusicBrainz, and only it so far): there a 503 to a GET is RateLimited. A POST’s 503 stays Server, since a 503, unlike a 429, does not promise the request was not acted on.GET, so a paid POST is never sent twice. A Retry-After, in seconds or as a date in any of HTTP’s three forms, replaces the backoff; one beyond the cap ends the retries.Retry-After is moved out of OkHttp’s sight, so it never repeats the request at once; and an origin’s 407, which OkHttp refuses as a protocol error, is Protocol, not retried.NetClient.stream, which hands a 2xx body to the caller line by line as it arrives; any other response is read whole and returned. The guarantees above hold: the rate limit and the retries apply only before the body, and once a line has reached the caller a failure (Offline for a lost connection, TooLarge past the request’s size limit, counted over the whole stream) is returned, never retried, so a paid stream is never sent twice. A stream follows no redirect, and its attempt is logged once, when it ends, also when the caller’s line handler stops it.GET are followed by :net, resolved as RFC 3986 says against the URL sent, each hop through its target’s rate limit, never with user info. :net protects the caller’s credentials; a value a server chose and handed back (a token in a Location, a presigned CDN URL) is not ours to keep from other origins, only from our logs. So a chain stays in its origin when the caller’s own request carries credentials: any NetSecrets, a header or query parameter named like a key, user info, or sensitiveUrl. It is decided once, from the caller’s request, and such a chain follows redirects within its origin only; to another origin the 3xx is returned, whatever the target looks like. Any other chain follows anywhere. A redirected POST is returned as it is.NetSecrets) unless it is hidden: its URL may hold a credential (sensitiveUrl, user info, a query parameter named like a key), or it is a hop after a redirect. Only the caller’s own URL is provably public; a Location is the server’s choice and may carry a token in its path or as a bare query name, so every hop after a followed redirect is hidden, in every chain. The cost is that keyless redirects (Cover Art Archive to archive.org) lose their path in the log. A hidden hop shows its path as /… and no query names. A keyed source’s first request (credentials only in NetSecrets or headers) still logs its path, which is public by contract. The contract: a request’s URL is public, and a key never goes in it unless the request sets sensitiveUrl (an API that wants the key in its path, a signed URL, a ?ticket=), which covers the path and query only; the host is always logged. A key goes in NetSecrets even when its wire name is not recognised as one (AcoustID’s client). A name is taken to mark a key, whatever its case, by its tokens (split on other characters than letters and digits, at letter/digit boundaries and at camelCase: X-Api-KeY, auth[token], key2, accessToken) or as a whole compound name with the separators removed (CLIENTSECRET, XAPIKEY, SESSIONID, OAuth’s code); not by substring, so author, keyword and languageCode do not, and a test pins the names the keyless sources use as public. Errors carry fixed labels and kinds, never a name, URL or message. :net does not depend on :core, so it does not see Secret: adapters pass Secret.reveal() into the request’s NetSecrets.| Constraint | Enforcement |
|---|---|
| No analytics, crash-reporting or ads SDKs | Gradle dependency locking, plus a checkDependencies task that fails on any resolved group outside an allowlist file. A new dependency is a reviewed edit to that file |
| No permissions beyond the expected set | A checkManifest task parses each variant’s merged manifest and fails on any uses-permission not in an allowlist file: INTERNET, and the app-private ${applicationId}.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION that androidx.core merges in. Other extras get tools:node="remove", or a reviewed allowlist entry |
| Network only through adapters and providers | Module graph: only :net depends on Ktor or OkHttp, only :sources, :ai and :cli on :net; a checkModuleGraph task asserts it. Detekt forbids java.net.URL, HttpURLConnection and socket APIs outside :net |
| Pipeline stays Multiplatform-ready (D2) | Detekt ForbiddenImport, ForbiddenMethodCall and ForbiddenAnnotation in the pipeline modules, as listed under Multiplatform readiness |
| Keys never logged | The Secret type in :core, revealed only into :net’s NetSecrets, and a log line that holds no value and no header or secret name, and for a hidden hop neither path nor query names; the contract that a key goes in a URL only with sensitiveUrl; unit tests log requests carrying a key in each of these places and compare the complete lines, which hold no key. No SLF4J binding ships (the allowlist admits slf4j-api alone), so Ktor’s own trace logs, which print URLs, go nowhere |
The pipeline modules are plain Kotlin JVM (D2), written so that a later switch to Kotlin Multiplatform, for an iOS target, is a build change plus a few small platform implementations:
:core, :sources, :ai and :store, nor in :net outside its HTTP engine factory (the one part that is per platform). Detekt checks three ways: ForbiddenImport for java.* and javax.*; ForbiddenMethodCall, with type resolution, for JVM-only APIs that need no import, configured per method or constructor since the rule takes exact signatures (kotlin.text.format, which String.format resolves to; System.currentTimeMillis, System.nanoTime; Thread and Math members); and ForbiddenAnnotation for @Synchronized. None of them is a complete check; only compiling a non-JVM target is, and that happens when the switch is made.:core: accent folding for the normalizer, hashing for the evidence fingerprint, and reading bundled data files (ResourceReader, for the genre vocabulary). Their JVM implementations are in each module’s src/jvmMain/kotlin, the directory a Multiplatform jvmMain would use, and a source set of its own: it is compiled after main, against main’s classes and internals, and main cannot see it, so portable code that uses a JVM implementation does not compile, however it is written. The module’s jar, its tests and its consumers carry both. jvmMain shares main’s dependencies: a checkJvmMainDependencies task fails when its compile or runtime classpath resolves other artifacts than main’s (apart from main’s own classes), whether through a declaration, an inherited configuration, a constraint or platform, a project or file dependency, a classifier or a variant, and fails first when either graph does not resolve. So the dependency allowlist and the module graph check see everything jvmMain uses, and its versions are the ones main has locked. Detekt checks jvmMain with the base rules only, so JVM APIs are allowed there, and nothing compiles from src/main/java or src/jvmMain/java.:net: a checkNetworkReferences task reads the compiled classes of main and jvmMain in each pipeline module and fails on any reference to java.net, javax.net, OkHttp or the Ktor client, whether the source imported it, wrote it out in full or aliased it. Its limits: a class reached by reflection, by name, and networking done inside a dependency’s own code, which the module graph and the dependency allowlist cover. :net, the module that wraps them, forbids only javax.net, which it does not need.kotlin.text.Regex, with portable constructs: no Unicode property classes such as \p{M}, no embedded flags, no lookbehind. Each platform has its own regex engine, and the normalizer’s tests run on the iOS target when the switch is made.kotlin.time; calendar dates and time zones, if needed, from kotlinx-datetime. Tests with kotlin.test and hand-written fakes.Each stage has a defined visible state, as the simulator shows: gathering evidence, writing, stopped, failed with retry, insights off, no evidence, AI service not set up.
GPL-3.0. Dependencies must be GPL-3.0 compatible (Apache-2.0, MIT, BSD are); the allowlist file records each one’s license.
The data sources carry their own terms, and every evidence line shows its source link:
| Source | Licence of what is used |
|---|---|
| MusicBrainz core data (recordings, releases, artists, relationships, and the genre list itself: names, aliases, MBIDs) | CC0 |
| MusicBrainz supplementary data (tags, including genre associations, and ratings) | CC BY-NC-SA 3.0; what it allows is below (D12) |
| Wikidata | CC0 |
| Wikipedia | CC BY-SA 4.0 |
| Last.fm bios | CC BY-SA; the API itself is under Last.fm’s API terms |
| Discogs | CC0 for the release data; the API under Discogs’ terms |
| Web search | Per provider’s terms; snippets only. Exa’s §4.2(a) is strict; what it means for the app is open, and the test fixtures are synthetic (design, Web search provider (D11)) |
Findings of #9, 2026-10-13, from MetaBrainz’s own pages, the Creative Commons texts and the AI providers’ API terms. Not legal advice: a reading of public terms by the project, to be confirmed with MetaBrainz before the tags ship (below). Whether the app uses the tags at all is D12’s: not to fill Last.fm’s gaps, decided at the Phase 0.5 gate (#46); as the labels when Last.fm is off, decided in Phase 2 (#95).
What is licensed how. MusicBrainz lists “user submitted annotations, tags (including genre associations) and ratings” as supplementary data under CC BY-NC-SA 3.0, and genres themselves (“Name, aliases, disambiguation comment, MBID”) as core data under CC0 (MusicBrainz Database, Data License). So the genre list is free to use; which genres an artist or recording has is not. inc=genres and inc=tags return the same tag votes, filtered or not (API); MetaBrainz says the same of genres when it introduced them (blog, 2018). Separately from the data licence, the web service itself says “Non-commercial use of this web service is free”; that covers every MusicBrainz call the app makes, core data included.
MetaBrainz’s own licensing path. Contributors “give the MetaBrainz Foundation the right to license this data for commercial use” (Data License), and its supporter page says: “If you are an open source developer, a small non-profit, or are using our data as part of an official university or research project, please sign up as a commercial user using the non-profit tier below” (supporter tiers, $0). So MetaBrainz routes open-source developers through its commercial licensing at no charge, not only through the CC grant. If such an account licenses supplementary data on MetaBrainz’s own terms, the NC and ShareAlike questions below may not arise; the page does not say, and the inquiry asks. The page frames the sign-up as being “to access our datasets or the MusicBrainz Live Data Feed”: registering is MetaBrainz’s request, not a known requirement for using the web service.
Facts. CC’s Data FAQ says “use of the facts and ideas embedded within the contents will not require attribution (or compliance with other applicable license conditions), unless doing so implicates copyright in the database structure”, that is, in an original selection or arrangement of the contents (CC Data FAQ). That a few tags or genres for one artist are such facts, and take nothing of the database’s selection or arrangement, is the project’s inference, not the FAQ’s. That argument applies to all three questions below. Display and labels also stand on the licence’s own terms; prompts do not (see the table). A further limit is the database right (below).
Who uses the data. Each phone fetches tags for the tracks its user plays, and shows them to that user. The project distributes code, not the data, with two exceptions: recorded HTTP responses in fixtures/http/, and the Phase 0.5 pilot’s own output (see Conditions).
| Question | Finding | Confidence |
|---|---|---|
| May the app show them, and with what attribution? | Yes under the CC licence, while the app is non-commercial: free, no ads, no paid features. NC turns on the use, not the user (CC, NonCommercial interpretation). Showing them on one user’s screen is arguably not distribution or public performance, which is what triggers the attribution condition (§4(d)), and the facts argument applies too; but crediting costs nothing. Attribution, “in any reasonable manner”: each tag-derived evidence line or label links the MusicBrainz entity page, credited “MusicBrainz contributors, CC BY-NC-SA 3.0” with the licence link, and the app’s credits screen (to be added with the app, see UI) lists MusicBrainz with both licences. Less certain is MetaBrainz’s own reading: it puts open-source authors on a non-profit tier of its commercial path, and its Bronze tier names “popular mobile apps”; the app’s donation links (.github/FUNDING.yml) add to that. The inquiry settles it |
High on the CC licence; medium-high on MetaBrainz’s reading until the inquiry is answered |
| Is sending them in prompts to a commercial AI provider, on the user’s key, within NC? | Likely, if the tags are treated as facts; unresolved if the CC licence is relied on. On NC, the use is the user’s, personal and unpaid; the provider processes the prompt as a service the user obtains on their own key, as a host carries it. Two points stay open. Content licence: providers’ terms make the user assure they hold the rights needed to submit input. Anthropic takes no licence (“Anthropic disclaims any rights it receives to the Customer Content”; the customer “has all rights and permissions required to submit Inputs”, Commercial Terms B, L.1). OpenAI’s API customer “retains all ownership rights in Input” and warrants “all rights, licenses, and permissions required to provide Input”, and grants “only the limited rights that are reasonably necessary for OpenAI to deliver the Services”, a permission that “also extends to subcontractors or sub-processors” (Services Agreement 4.1, 4.3, 9.2; PDF). Whether passing that limited permission on to sub-processors counts as sublicensing under the CC licence is unresolved, but it is far narrower than Google’s licence (below). Google’s API terms take a “perpetual, irrevocable, worldwide, sublicensable” licence to submitted content and require “the necessary rights … to grant us the license” (Google APIs Terms 5(b)); CC BY-NC-SA 3.0 forbids sublicensing (§4(a)). For protected material that conflict holds on any Google tier; for facts there is nothing to license. Data use: what the provider may do with the prompt depends on the terms that apply to that key, not on the provider. Gemini’s “Unpaid Services” (no active billing account) use content to “improve, and develop” Google’s products, but users in the EEA, Switzerland and the UK get the paid-service data terms even on unpaid quota (Gemini API terms). Anthropic’s commercial terms forbid training on customer content; OpenAI does not use it to improve its services unless the customer agrees. A local model sends nothing. Separately, the Gemini terms allow only Paid Services when making API clients available to users in the EEA, Switzerland and the UK; that bears on the Gemini adapter (Phase 3) and is not decided here. CC will not rule on edge cases and says to ask the licensor (FAQ) | Medium-low until MetaBrainz answers; lowest for Google keys |
| Are genre labels made from them through the fixed vocabulary an adaptation that carries ShareAlike? | No, on the usual reading. ShareAlike (§4(b)), like attribution (§4(d)), binds only distributing or publicly performing an Adaptation: labels computed and shown on one user’s phone are neither, whatever they are. Nor are they likely an adaptation, which needs new copyrightable creativity based on the work (FAQ, adaptations); filtering one artist’s tags through the app’s own vocabulary yields facts (“tagged rock”). Where ShareAlike does apply, it binds the adapted data when shared, never the GPL-3.0 code that processes it: the code is not based on the data. The vocabulary may also take names from the MusicBrainz genre list freely, since that list is CC0 | Medium-high on labels; high on the code |
Database right. CC 3.0 Unported does not license sui generis database rights (Data FAQ). The EU right (Directive 96/9/EC, Art. 7, 11) protects a substantial part, and also the repeated and systematic extraction of insubstantial parts; as CC puts it, “using several insubstantial portions can add up to a substantial portion” (Data FAQ). Article 11 gives the right to makers or rightholders who are EU nationals or residents, or companies established in the EU; the MetaBrainz Foundation is a US non-profit (about), which makes the right unlikely to apply, though who counts as maker or rightholder here is not confirmed. Ordinary personal lookups, one user’s tracks as they play, are likely low risk; request rate says nothing either way. A bulk or offline dataset would not be low risk.
What would change the findings.
.github/FUNDING.yml: Patreon, Buy Me a Coffee; the README asks for none): voluntary support of the developer, not tied to the data or to features, is generally not “directed toward … private monetary compensation” (licence §4(c)). Gating any feature behind a donation, or a Patreon tier that unlocks one, would be.Before the Phase 2 decision (#95), and in any case before tags ship in the app. Ask MetaBrainz (support@metabrainz.org, or the forum), and register on the non-profit tier if MetaBrainz confirms it covers web-service use. The questions: may a free, ad-free GPL-3.0 app with donation links show tags and genre associations; does a non-profit tier account license supplementary data beyond CC BY-NC-SA 3.0, and on what terms; is sending a track’s tags in a prompt to an AI provider on the user’s own key non-commercial use, and may the user grant the provider the content licence its terms require (Google’s is sublicensable); and do genre labels drawn from them through a fixed vocabulary need ShareAlike. A written yes settles the open rows.
Conditions, if Phase 2 adopts them as labels (D12).
fixtures/http/ that include tags, and the Phase 0.5 pilot’s reports, CLI output committed to the repository, and issue or PR comments that list an artist’s MusicBrainz tags. Committed or posted pilot output gives aggregates only (counts, label coverage, agreement with Last.fm); anything that lists tags is attributed and marked CC BY-NC-SA 3.0, as recordings that keep inc=tags or inc=genres must be (or they drop those includes). This is the same rule plan.md sets for third-party text in recordings (plan, During implementation). No bundled or offline tag dataset.Remaining risk. Low for display and labels on the CC licence; medium-high confidence on MetaBrainz’s reading until it answers. For prompts, the open point is the provider’s content licence, sharpest with Google’s sublicensable licence, and then the data-use terms that apply to the key (Gemini’s unpaid tier outside the EEA, Switzerland and the UK trains on input). The inquiry removes the MetaBrainz-reading doubt; the provider-terms point stands unless MetaBrainz answers it or the tags stay out of prompts.
| Area | Choice | Main alternative | Why |
|---|---|---|---|
| Language | Kotlin, JVM 17 bytecode | Android standard | |
| Build | Gradle Kotlin DSL, version catalog, convention plugins in build-logic/ |
Shared config across the modules | |
| Async | Coroutines, Flow | collectLatest is the cancellation model |
|
| HTTP | Ktor client, OkHttp engine | Retrofit | Same client on JVM and Android, streaming-friendly, one dependency boundary |
| JSON | kotlinx.serialization | Moshi | Multiplatform-ready, no reflection |
| Database | SQLDelight | Room | Plain JVM and Android drivers from one schema, for the CLI and the app |
| UI | Jetpack Compose, Material 3 | Design decision | |
| Dependency injection | Manual constructor wiring, one AppGraph |
Hilt, Koin | Small graph; keeps :core free of DI annotations |
| Static analysis | Detekt, ktlint, Android lint | Detekt carries the forbidden-API rules | |
| Tests | kotlin.test on the JUnit 5 runner in the JVM modules, with hand-written fakes rather than a mocking library; JUnit 4 in :app, where Robolectric needs it; kotlinx-coroutines-test, Ktor MockEngine |
MockK | JVM-first, fast in CI, and portable to Multiplatform (D2) |
| Dates and times | kotlin.time for instants and clocks; kotlinx-datetime for calendar dates, if needed |
java.time |
Multiplatform-ready (D2) |
| Android SDK | minSdk 26, targetSdk the latest stable |
minSdk 29 |
See D10 |
playlist-data, facts-data, albums-data) move to fixtures/below-soundtrack/, and the design’s regression cases become named tests. The facts-data resolution outcomes are the expected outcomes of the resolution tests; its fact lines are reference facts the extraction tests check for by content, not wording.:core: normalizer, scoring, genre vocabulary, evidence ids and fingerprint, grounding check, prompt builders against stored prompts.:store, on an in-memory SQLite database through the JDBC driver, with a clock the test moves: each cache’s round trip, expiry per source, purging, eviction under the size limit in least-recently-used order, every part of the synthesis key, and versioned invalidation.fixtures/http/. Tests replay them through ReplayNetClient, a NetClient in :sources’ test fixtures that serves the recorded files (#36): NetClient is an interface, so the adapters need no Ktor MockEngine, which Detekt keeps inside :net. Until :cli record exists, a recording run of the same lookups prints each exchange and a script writes the files (fixtures/http/README.md). CI never calls a live service.:app under Robolectric (JUnit 4): mapping MediaMetadata and PlaybackState to NowPlaying and PlaybackInfo, dropping players not followed, and offering only advertised transport actions.kotlinx-coroutines-test virtual time: debounce, cancellation on a superseded track, cache reuse, the auto-write setting (D5) in each of its modes, including no write before the first user action; preparing the next track (D13) in each of its modes, including a dropped preparation when the queue changes; and a style (D14) changing the prompt but not the restated contract.:cli run with assertions: zero unknown citations, uncited under 5 percent, every listed collision rejected or correctly resolved.How the CLI is made (#45), where the design leaves room:
replay answers every request from fixtures/http/ and fails on a source’s request with no recording; CliReplayTest runs it over the regression tracks in every build, so CI runs the CLI end to end without network or keys. live uses the developer’s keys from the environment or ~/.config/sleevesight/keys.env. record is live for the sources named and replayed for the others, and writes the live answers into fixtures/http/ (fixtures/http/README.md). The tracks run one after another, each through Pipeline.run, with the cache a fresh temporary database in replay (so the playlist’s duplicates hit it), .dev/cli/cache.db live, and none when recording.fixtures/http/README.md).Gate): the two citation criteria over every written insight, not checked when none is written; the collisions are the design’s regression cases with a same-name or same-title namesake (tracks 8 and 22, 12 and 20, 45, 48 and 76, 61), each with what a right answer looks like per source (no Wikipedia song lines for the covers, whose original-song lines are right; no artist lines for the wrong Reem; Last.fm rejected for Jessiah), checked only where the source ran; an unreachable or failed source on a listed track leaves its check unchecked, and the criterion not checked (#45 review: it had passed). The citation criteria count the insights written of those with evidence and name the failed ones; a written insight that fails a criterion fails it, and only when none does is a criterion left not checked because some insight call failed. The exit status is 1 when a criterion fails, 2 for a usage error or a source’s request with no recording, 3 when a criterion the run should have checked could not be.GitHub Actions from the first code PR. On every pull request and every push to main or a dev-* integration branch:
./gradlew build: compile, unit tests, Detekt, ktlint, Android lint.checkDependencies, checkManifest, checkModuleGraph.assembleDebug, with the APK uploaded as a workflow artifact.While no Android module exists (from the removal of :spike until :app), Android lint, checkManifest and assembleDebug have nothing to run, and CI leaves the last two out.
A release workflow (a signed app bundle for Google Play, D7) comes with the first release; the signing key goes in a protected environment, not a repository secret. main requires the build check, so every pull request runs it, Markdown-only ones included: a required check that never reports leaves the pull request unmergeable. A push to main or a dev-* branch that changes only Markdown runs no CI. design/simulator.html is not Markdown, so a change to it does run CI, and a :core test checks that its embedded data agrees with the fixtures.
| Phase | Modules built | Exit |
|---|---|---|
| 0. Platform spike | :spike; CI skeleton (build-logic, the three checks, empty :core) |
Design’s Phase 0 gate; D5’s skip detection measured (the time left at a change, Spotify’s state clusters, no skip state reported); art-URI players listed, and whether the content:// grant holds; installation from a file tested (D7, which moved distribution to Google Play); :spike removed. Passed 2026-10-13; restricted settings moved to Phase 1 |
| 0.5. Pipeline CLI | :core, :net, :sources (MusicBrainz, Wikipedia and Wikidata, Last.fm, web search), :ai (Anthropic first), :store, :cli, fixtures and recordings |
Design’s Phase 0.5 gate; MusicBrainz tags judged (D12); the first web search provider chosen (D11). Passed 2026-10-15 |
| 1. Android MVP | :app: session layer, home screen, sources section, insight, settings, secrets, cache, diagnostics; preparing the next track (D13); recording track changes (D15) |
Runs on a real phone against Spotify |
| 1.5. Play release | Release workflow (a signed app bundle, see CI); store listing, privacy policy, data safety form | Published on Google Play (D7) |
| 2. Detail and discovery | Detail content, genre and sounds-like cards, layout setting, transport and handoff; the prompt style with its overrides per card (D14); learned skip detection (D15), here or later | |
| 3. Context and reach | Spotify Web API (PKCE), more players, Ollama, Discogs, further AI providers |
Each question was put with a recommendation; ✅ marks it decided by the owner (all of D1 to D15, on 2026-10-12). The Decision column records the decision as made. Where the decision differs from the first recommendation, the row has the decision as made: D2 (kept Multiplatform-ready, as iOS may follow), D5 (three options instead of one rule, and later the default), D6 and D9 (user settings), D7 (Google Play, after Phase 0), D8 (HTTPS through a reverse proxy rather than cleartext), D12 (judged in the pilot, with the licence a separate question). D13 to D15 were decided after this document was first written. All of them are also in the design’s Decisions table.
| # | Question | Decision |
|---|---|---|
| D1 ✅ | How are evidence lines made from prose sources (R1)? (a) verbatim selected sentences, attributed; (b) a model extraction pass; (c) structured data only | (a), with (c) wherever structured data exists. Deterministic and checkable, no extra paid call, and the model still never decides what is true. Change the design’s “never copied prose” to “short attributed quotes, never whole passages” |
| D2 ✅ | Where does the pipeline live: plain JVM modules, or Kotlin Multiplatform? See considerations | Plain JVM, kept Multiplatform-ready. The CLI and Android both run JVM bytecode, and Multiplatform buys nothing until there is a non-JVM target. iOS may follow once the Android prototype is good, so the pipeline modules follow the rules under Multiplatform readiness, which keep a later switch to about a week. Decided 2026-10-12 |
| D3 ✅ | Vendor SDKs or small HTTP clients for the AI providers? See considerations | Small clients, for one network boundary, a small allowlist, and Multiplatform readiness (D2), which the JVM-only vendor SDKs would break. Decided 2026-10-12 |
| D4 ✅ | Wait for MusicBrainz before Wikipedia and Last.fm, or query them by name in parallel and verify afterwards (R3)? | Parallel by name, verified afterwards through Wikidata (the check in step 5), so a slow MusicBrainz delays only its own lines. Phase 0.5 measures both. Decided 2026-10-12; the calls are to be profiled once the adapters exist |
| D5 ✅ | How is a user skip in the player told from a track ending (R5)? | One user setting, Write insights automatically: on every track change, when I skip, or never. The default was first When I skip; it is On every track change (owner, 2026-10-12), to go with preparing the next track ahead (D13). Skips are detected from the time left before the end and, for Spotify, the late cluster of playback states it publishes before a natural end, under a Crossfade setting (Autodetect, or 0 to 15 s), refined after Phase 0. See Writing insights automatically. Decided 2026-10-12 |
| D6 ✅ | Run lookups while the app is not visible (R7)? | No. History always; retrieval and synthesis only while visible, and the visible track catches up on resume. Decided 2026-10-12 as the default of a user setting, Look up in the background, off (driver 6) |
| D7 ✅ | Distribution: Play Store, F-Droid, GitHub releases? (R8) | Google Play (owner, 2026-10-12). The first decision was GitHub releases first and F-Droid next, with Play’s review of notification-listener apps left for later. Phase 0 reversed it: an app installed from a file meets a Play Protect warning (unknown developer, Install anyway), and with a Google account enrolled in Advanced Protection it cannot be installed at all, only from Google Play or a store pre-installed on the phone (design.md, Notification access and installation). Play’s review of an app that holds notification access is therefore part of the release work, not deferred. Decided 2026-10-12 |
| D8 ✅ | How does the app reach a local model through Ollama (R9)? | HTTPS through a reverse proxy that terminates TLS in front of Ollama is the supported setup. A publicly trusted certificate (for example from Let’s Encrypt, or tailscale cert) works as is. A self-signed or private-CA certificate is trusted for that one host only: :net gives that host its own client with a custom X509TrustManager that accepts exactly the pinned certificate, whose fingerprint the user confirms on the first connection, and checks the host name against the pin rather than the certificate’s names (OkHttp’s CertificatePinner alone is not enough, since it runs after the platform’s chain validation, which fails first). User-installed CAs are never trusted app-wide. Plain http:// remains a fallback, allowed only to the configured Ollama host and only when the address it resolves to is private: IPv4 private or link-local, IPv6 unique-local (fc00::/7, which includes Tailscale’s) or link-local, or 100.64.0.0/10, which Tailscale uses but carrier-grade NAT shares, so it gets the same warning. The resolved address is checked, not the name, and at connect time: that client’s OkHttp Dns returns only private addresses, so the address checked is the one connected to (no DNS rebinding), and it uses Proxy.NO_PROXY so a system proxy cannot skip the lookup; .local names may not resolve before Android 12, so Settings accepts an address as well. That needs the platform guard relaxed (cleartextTrafficPermitted) and replaced by :net’s own check, with tests; Settings warns that the link is unencrypted, and its insights carry an “unencrypted link” marker. Phase 3. Decided 2026-10-12 |
| D9 ✅ | Retrieval cache lifetime per source? | A user setting per source, defaulting to MusicBrainz and Wikidata 30 days, Wikipedia and Last.fm 7 days, web search 1 day. Decided 2026-10-12. Discogs: 30 days, as for MusicBrainz (owner, 2026-10-13) |
| D10 ✅ | minSdk? |
Nothing used forces it: the media-session APIs date from API 21 and AES-GCM Keystore keys from 23. It is reach against the number of OS versions to test. 26 (Android 8): more phones, and still above the Keystore floor; 29 would drop three versions of behaviour to test at the cost of reach. A build-time choice, so not a user setting (driver 6). Decided 2026-10-12 |
| D11 ✅ | The first web search provider (an open question in the design)? | Deferred to Phase 0.5; the adapter interface does not depend on it. Decided 2026-10-12. Chosen in #40, 2026-10-14: Exa, for its free tier without a card and its results on the thin tracks; only Exa tested live. Comparison and filtering in the design’s Web search provider (D11). Its results are kept in the on-device retrieval cache for a day during development (owner, 2026-10-14; #81, reversing the #44 review); whether Exa’s §4.2(a) allows that in the app is open (#80), to settle before publishing |
| D12 ✅ | Use MusicBrainz tags and genres (R6)? | Established in the Phase 0.5 pilot: pull them for the test playlist and judge what they add to the genre labels and the insights. Their licence (CC BY-NC-SA, including whether sending them in prompts to commercial AI providers is within the NC term) is a separate technical question, tracked in #9 and settled before they ship in the app. Decided 2026-10-12. Licence findings (#9, 2026-10-13): see MusicBrainz tags and genres. Outcome, 2026-10-15 (the gate, #46): not adopted to fill Last.fm’s gaps, which they fill 3 of 30; whether they are the keyless labels when Last.fm is off is decided with the genre labels in Phase 2 (#95) |
| D13 ✅ | Prepare the next track before it plays? | Yes, as a user setting, Prepare the next track: off, evidence, or evidence and insight (the default). See Preparing the next track. Tracked in #20. Decided 2026-10-12 |
| D14 ✅ | Can the user shape how insights are written? | A prompt style, a user setting with presets, free text and overrides per card, on top of a fixed contract that it cannot override. See Prompt style. Tracked in #21. Decided 2026-10-12 |
| D15 ✅ | Can skip detection (D5) improve per player? | Learned on the device, without an LLM: features of each track change recorded from Phase 1, a small explainable model per player later, trained on trustworthy labels only, within the fixed rules. See Learning skip detection. Tracked in #22. Decided 2026-10-12 |
<script> is removed from design/simulator.html, and the simulator links point to its GitHub Pages copy, which has no AI service and shows the first facts found in place of written insights (R10; #14).