Sleevesight

Sleevesight architecture

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.

Contents

Review of the design

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.

What the implementation can port from the simulator

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.

Findings

# 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.

Architectural drivers

In order of weight:

  1. The pipeline runs off the phone. Phase 0.5 is a JVM command-line tool sharing the pipeline with the app. Everything from normalization to the grounding check is plain Kotlin with no Android dependency.
  2. The model never decides what is true. Resolution, matching, evidence selection and citation checking are deterministic and unit-tested. The model gets a prompt and returns text; it has no tools.
  3. Constraints are structural. No analytics, no extra permissions, no network access outside the adapter and provider layer. Each is a build failure, not a review comment.
  4. Failure is visible. Every source returns a typed outcome; every stage logs to the diagnostics stream; nothing degrades to a blank.
  5. Cost is the user’s. Every model call spends the user’s key, so calls are explicit, cached and cancellable.
  6. The user decides. Where a runtime behaviour is a judgement call rather than a correctness rule (cache lifetimes, the crossfade of D5, what runs in the background), it is a user setting with a reasoned default, not a fixed developer choice. Correctness rules stay fixed: the grounding check, the network boundary, the resolution thresholds. Build-time choices such as 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.

Modules

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.

Runtime

Data flow

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

Concurrency

State

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:

Components

Session layer (:app)

Normalizer (: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).

Entity resolution (: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.

  1. Recording. Search MusicBrainz by normalized title and credited artists. Score candidates on title similarity, artist-credit overlap, and duration within a tolerance (computed locally, R4). Accept above a threshold, otherwise none; a near miss with a conflicting artist is rejected(reason).
  2. First release. From the accepted recording’s releases, take the earliest release-group first-release date. The release the player reports stays separate (P evidence). Both are kept; which one the UI shows is still the design’s open question on release-date semantics.
  3. Artists. Each credited artist resolved separately, to an MBID, cached long-term.
  4. Cover detection. The recording’s work relationship and the work’s earliest recording by a different artist give original_song.
  5. Wikipedia identity. With a MusicBrainz match: MBID to Wikidata through the MusicBrainz URL relationship, then the English Wikipedia sitelink. For an article found by name: the article’s own Wikidata item (its 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:

How step 5 is made (#37, IdentityCheck), where the step leaves room:

The Wikipedia adapter (#38, WikipediaSource), where the design leaves room:

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.

Source adapters (: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 verification

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:

  1. Last.fm’s artist and the resolved artist both have a MusicBrainz id: the same id verifies the match, even under a corrected name; a different id rejects it.
  2. Otherwise, a name Last.fm corrected (it differs from the credit beyond case, accents, spacing and punctuation) is rejected. Autocorrection is how a small act lands on an unrelated band’s page: the fixture’s track 61, 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.
  3. Otherwise the match is unverified: the name is the same, and nothing confirms the artist. With no resolution (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.

Evidence (:core)

Synthesis and grounding (:core, :ai)

Prompt style

The user shapes how insights are written, within limits the grounding check depends on (D14). Each prompt has two parts:

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.

Genre labels (: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.

Cache and history (: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:

Settings and secrets (:app)

Writing insights automatically

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):

Learning skip detection

Skip detection gets better per player from data, on the device, without an LLM (D15).

Preparing the next track

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

Spotify account (Phase 3)

UI (:app)

Diagnostics

Cross-cutting concerns

Networking

Structural constraints

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

Multiplatform readiness

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:

Errors and empty states

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.

Licensing

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))

MusicBrainz tags and genres (#9)

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.

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).

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.

Technology choices

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

Testing

How the CLI is made (#45), where the design leaves room:

CI

GitHub Actions from the first code PR. On every pull request and every push to main or a dev-* integration branch:

  1. ./gradlew build: compile, unit tests, Detekt, ktlint, Android lint.
  2. checkDependencies, checkManifest, checkModuleGraph.
  3. 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 mapping

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  

Decisions

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

Follow-ups outside this document