Melodix

Architecture

Use this guide to follow components and failure paths. For a code change, start with Conventions; lifecycle contracts live in Ownership.

Melodix is a Discord music bot built on top of a playback engine that doesn’t know Discord exists. Two frontends share one application layer, internal/music, over that engine, and each has its own binary:

  • cmd/discord — the Discord bot (internal/discord): slash commands, voice, persistence, health watchdogs.
  • cmd/cli — the terminal player (internal/cli): the bot’s music commands in a REPL, playing to your local speaker. It is also the proof that the layers under it have no Discord dependency.

A third binary, cmd/readme, regenerates the README’s command lists from both frontends’ catalogs.

Each frontend is laid out the same way: a root package that owns the transport (the gateway and voice for the bot, stdin and the speaker for the CLI), a command/ tree with one package per command, and command/catalog, the single list its binary registers.

flowchart TB
  subgraph Frontends
    DiscordBot["internal/discord"]
    CLI["internal/cli"]
  end
  Music["internal/music<br/>(players per scope, Add, Search, History)"]
  subgraph Engine["pkg/music (Discord-agnostic)"]
    Resolver["resolve.Resolver<br/>(input → TrackInfo)"]
    Player["player.Player<br/>(queue + playback loop)"]
    Stream["stream.RecoveryStream<br/>(parser fallback + retry)"]
    Parsers["parsers: ytnative | scnative | kkdai | ytdlp | ffmpeg<br/>(track → 20ms Opus packets)"]
    SinkIface["sink.AudioSink"]
  end
  DiscordBot --> Music
  CLI --> Music
  Music --> Player
  Player --> Resolver
  Player --> Stream
  Stream --> Parsers
  Player --> SinkIface
  SinkIface -->|forward Opus packets| DiscordVC["Discord voice"]
  SinkIface -->|decode → oto| Speaker["Local speaker"]

What this optimizes for

  • Playback that survives a bad link. The bot is expected to run from a throttled or lossy connection, so recovery, read-ahead and the cost of re-fetching are first-class concerns rather than polish. Where a choice trades startup latency for continuity, continuity wins.
  • No hard dependency on external binaries for YouTube. ffmpeg and yt-dlp are optional; the passthrough paths need neither.
  • An engine that does not know Discord exists. pkg/music is reusable on its own terms and the CLI is the standing proof.
  • Expendable parsers. Any single extraction path can break without notice when a platform changes. The fallback chain is what makes playback reliable — no individual parser is.

Non-goals: audio effects, mixing across guilds, a web UI, and anything that would require signature deciphering.

Glossary

These terms name separate stages of the playback system.

Term Means
source Input → metadata. Resolves a URL or query to TrackInfo; never touches audio.
parser Track → 20ms Opus packets. Owns extraction and the stream URL.
sink Opus packets → audio out: Discord voice, or the local speaker.
passthrough Forwarding a source’s Opus untouched — no decode, no re-encode, no ffmpeg.
transcode The other path: ffmpeg decodes to PCM, opus.Encode re-encodes to packets.
link vs pipe Two modes of one parser: hand ffmpeg a URL (link), or stream bytes through its stdin (pipe).
media recovery RecoveryStream reopening a failed source. Budgeted per parser.
transport recovery runPlayback reacting to a dead Discord voice connection. A separate budget.
instant fail A parser that opened but errored on its first read; the chain moves on rather than retrying.

Package map

Path Responsibility
pkg/music/player Player: FIFO queue, playback goroutine, transport recovery, status channel
pkg/music/resolve Resolver: input → []TrackInfo; source detection and precedence
pkg/music/sources Source interface (+ optional Searcher) and youtube, soundcloud, radio implementations; YouTube also expands playlists and mixes
pkg/music/innertube The YouTube InnerTube client identity — constants and the request context — shared by the ytnative parser and the youtube source so the client version has one place to be bumped
pkg/music/parsers Streamer interface + ytnative, scnative, kkdai, ytdlp, ffmpeg implementations
pkg/music/opus The engine’s currency: Reader (20ms Opus packets), a zero-dep WebM demuxer (passthrough), encode/decode adapters over godeps/opus, and a read-ahead BufferedReader (anti-skip); 48 kHz / stereo / 960-sample constants
pkg/music/soundcloudapi Minimal SoundCloud api-v2 client (rotating client_id, resolve, stream URLs, search) shared by scnative and the soundcloud source
pkg/music/stream Parser registry + RecoveryStream (packet-level recovery, live-stream reconnect; optional cache-first read and write-through tee, with the read-ahead buffer wrapped around it)
pkg/music/cache Optional global, content-keyed track cache: tees played Opus packets to disk blobs and serves them on later plays (any guild); LRU size cap, persistent by default
pkg/music/sink AudioSink/Provider interfaces + speaker implementation
internal/discord The Bot: session lifecycle, handlers, health watchdogs, voice service
internal/discord/voice Guild status messages, voice state, failure reports — the hooks the bot plugs into music.Service; survives session restarts
internal/discord/voice/voicesink Joins a voice channel and forwards a track’s Opus packets to it (no encode); holds frames the transport cannot protect
internal/discord/adapter Bridges melodix command types to the keshon/command registry/middleware framework
internal/discord/slashsync Per-guild slash-command diff sync (create/edit/delete)
internal/discord/audit The audit trail: who ran what, where, persisted — distinct from the diagnostic log
internal/discord/reply Embed/response helpers shared by handlers and the voice service
internal/discord/queue How commands get scheduled: one FIFO lane per guild, drained off the gateway read goroutine so a guild’s commands stay ordered and never overlap, plus the global cap on how many run at once across every guild
internal/discord/watchdog Gateway-silence detection and WS/ready tracking
internal/discord/command The bot’s commands, one package each (music/play, core/help, settings, …); catalog registers them all
internal/discord/middleware The chain every command and click runs through: guild-only, permissions, disabled groups, the command log
internal/cli The terminal frontend: the REPL, Command/Context/Registry, and the player’s status printer
internal/cli/command The CLI’s commands, mirroring the bot’s layout (music/play, core/help, …); catalog registers them all
internal/readme Renders both frontends’ command lists into README.md for cmd/readme
internal/music The application layer the CLI and the bot share: one player per scope (guild or terminal) with its sink provider; Add (history ids, links, queries), Search, History; history recording; the optional playback layers (cache, anti-skip read-ahead). It never starts playback — each frontend does, so it can announce the start its own way
internal/config Env-driven config (caarlos0/env + .env); all runtime knobs live here
internal/storage Persistence: schema (guild settings, command log, playback rows, cache index) and the collections/indexes declared on the embedded datastore

External process dependencies: ffmpeg is optional, used only by the transcode parsers — SoundCloud AAC, radio, and the kkdai-link/ytdlp-* fallbacks. YouTube itself plays back via Opus passthrough (ytnative-link and kkdai-pipe), with no ffmpeg involved at all, so a YouTube-first bot doesn’t need either binary. yt-dlp is the last resort in the ytdlp-* chain, and optional for everything except YouTube live broadcasts — those are HLS, which no other parser here can follow. Both paths default to PATH but can be overridden (ffmpeg.FFmpegPath / ytdlp.YtdlpPath).

yt-dlp additionally wants a JavaScript runtime on PATH (deno, node or bun). Left to its own defaults it falls back to a YouTube client googlevideo serves under restrictions, and live streams stop after twenty-odd seconds; where a runtime is found, pkg/music/parsers/ytdlp/runtime.go selects the embedded web client on every invocation. Where none is found it passes no flags at all, because asking for that client without a runtime fails outright rather than degrading. Discord is disgoorg/disgo, and end-to-end voice encryption is thomas-vilte/dave-go — a pure-Go MLS implementation, which is used by the six release targets. The workflows enable CGO for Linux builds and disable it for Windows and macOS; the Linux speaker dependency needs ALSA.

The three extension layers

Everything pluggable sits behind one of three small interfaces:

// pkg/music/sources — URL/query → track metadata (no stream URLs yet)
type Source interface {
    Match(input string) bool
    Resolve(input string, selectedParser string) ([]TrackInfo, error)
    SourceName() string
    AvailableParsers() []string
}

// pkg/music/parsers — track → 20ms Opus packets (opus.Reader)
type Streamer interface {
    // The track is a copy; what the parser learns comes back in Opened
    // (reader, cleanup, passthrough, title/artist/duration).
    Open(track Track, seekSec float64) (Opened, error)
}

// pkg/music/sink — Opus packets → audio output
type AudioSink interface {
    Stream(r opus.Reader, stop <-chan struct{}) error
}
type Provider interface {
    Sink(target string) (AudioSink, error)
    ReleaseSink(target string)   // player disconnected (leave VC)
    InvalidateSink()             // drop cached transport, next Sink() re-acquires
}

TrackInfo deliberately carries just a page URL, a title, the source name, and an ordered parser preference list — nothing more. Actual stream URLs are resolved lazily, by the parser, at open time, so a queued track never holds an expiring CDN link.

Resolution

resolve.New() registers the three sources. Resolve(input, source, parser) tries these in order:

  1. An explicit source was selected — validate the parser, then: a bare query is only allowed for the searchable sources (YouTube, SoundCloud); a URL has to pass Match.
  2. Auto-detect, bare query — always routed to YouTube.
  3. Auto-detect, URL — deterministic precedence: YouTube first, then SoundCloud. (Map iteration is deliberately never used for matching — a new source has to be added to this list by hand.)
  4. Fallback — radio, which validates the URL by probing its Content-Type.

For search: both searchable sources return []sources.SearchResult through the optional sources.Searcher interface. YouTube posts to InnerTube’s search endpoint with the video-only filter, so channels and shelves never reach the caller; SoundCloud goes through api-v2’s /search/tracks, via the shared soundcloudapi client. A result carries the source’s own compact id rather than a URL, because /search has to round-trip it through a Discord component id — see docs/conventions.md for why that is a frozen format.

Playlists and mixes. A YouTube link carrying a list= expands to many tracks rather than one. A stored playlist (PL…, UU… channel uploads) comes from InnerTube’s browse endpoint with browseId: "VL"+listID, paged by its continuation token; a mix (RD…, including RDMM personal mixes, RDAMVM song radio and RDCLAK curated lists) is generated per request, has no browsable page at all, and comes from next instead. Expansion is capped at youtube.MaxPlaylistItems (100) — playlists run to thousands of entries and mixes are formally endless.

A link that names a video and a list starts at that video and keeps the rest, which is what YouTube itself means by such a URL. If the named video is not among the fetched entries — past the cap, or not a member — it is prepended, so the video the user pointed at is never dropped.

YouTube: Opus passthrough and the fallback chain

YouTube audio (itag 251) is already 48kHz stereo Opus inside a WebM container — Discord’s exact wire format — so the goal is to forward it untouched. opus.Passthrough demuxes the WebM and hands the packets to the sink with no ffmpeg, no decode and no re-encode. It validates that the first packet is a single 20ms frame, the only shape the Discord sender can forward, and falls back if it is not. The chain, in order:

  • ytnative-link (passthrough) — the InnerTube player endpoint under the VISIONOS client, giving a direct cipher-free URL, fetched in ranged chunks. Client identity, session id, fetch shape and live-stream behaviour are documented where they are implemented: pkg/music/innertube, parsers/ytnative/visitor.go, parsers/ytnative/chunked.go.
  • kkdai-pipe (passthrough) — kkdai/youtube resolves a WebM/Opus stream and downloads it in chunks, demuxed the same way. Rides the same client; see parsers/kkdai/streamer.go.
  • kkdai-link, ytdlp-* (transcode) — the ffmpeg-encode fallbacks, reached only once both passthrough paths are exhausted.

The client choice is a system-wide constraint, not a parser detail. googlevideo applies per-issuing-client rules to the URLs it hands out: an ANDROID_VR URL answers 403 to any open-ended request and serves only bounded ranges of roughly 1 MiB, while a VISIONOS URL serves both shapes. ffmpeg sends Range: bytes=0- and ytnative’s fallback opens an unbounded response, so the identity in pkg/music/innertube is load-bearing for the whole chain — which is why it lives in a package of its own rather than inside one parser. TestLiveOpenEndedRequestAccepted pins it.

ytnative returns ErrCipherOnly on cipher-only responses; a passthrough that fails framing validation returns opus.ErrNotPassthrough. Either way, recovery moves on to the next parser.

SoundCloud (scnative)

scnative runs on pkg/music/soundcloudapi: the rotating client_id gets scraped from the web player’s JS bundles, cached, and refreshed automatically whenever it hits a 401/403. Tracks resolve via /resolve, and whichever transcoding is preferred (AAC HLS over HLS over progressive) gets transcoded by ffmpeg and encoded to Opus packets — SoundCloud’s AAC just isn’t passthrough-able. Radio streams go through the same ffmpeg transcode path.

A track’s “Now Playing” chip shows passthrough, ffmpeg, or cached, so you can tell at a glance which mode is actually active. The passthrough packages also have opt-in live tests (MELODIX_LIVE_TESTS=1 go test -run Live -v ./...) that act as canaries for endpoint drift.

Track cache & anti-skip buffer

Two independent layers wrap the parser stream, and where each sits is the architectural point. The cache is off by default; read-ahead defaults to 30 seconds. See the configuration reference for both settings.

  • Track cache (CACHE_ENABLED) sits inside RecoveryStream, above the recovery logic, so one blob spans parser switches and transport reopens and is committed only on a clean end. Open checks the cache before the parser list, so a later play of the same track — from any guild — serves from disk with no extraction and no ffmpeg. A miss or a bad blob falls through to the parser chain, so the cache can never block playback. Mechanics and the on-disk format live in pkg/music/cache.
  • Anti-skip buffer (BUFFER_AHEAD_MS) sits outside it, wrapped around RecoveryStream through Packets(). Below recovery, a reopen tore the buffer down along with the stream it wrapped and the consumer blocked for the whole reconnect; above it, the queued lead keeps playing while the reopen happens underneath, and it survives transport reopens too. It also moves ReadPacket onto a read-ahead goroutine, which is why RecoveryStream guards reader/cleanup with a mutex and why Close signals, tears the source down to unblock the producer, then waits.

Whether the buffer helps at all depends on the source outrunning playback, which is not a given — see parsers/ytnative/chunked.go for what that took on YouTube.

Playback pipeline

sequenceDiagram
  participant H as Slash handler
  participant P as player.Player
  participant RS as RecoveryStream
  participant S as AudioSink (Discord)
  H->>P: EnqueueTrackInfo(track)
  H->>P: PlayNext(voiceChannelID)
  P->>P: dequeue under playNextMu
  P->>RS: Start(0) — first parser that opens
  RS-->>P: OpenInfo (which parser, passthrough, cached, title)
  P->>P: apply OpenInfo to the track under p.mu
  P->>P: spawn runPlayback goroutine for run N
  P-->>H: nil (track started)
  H->>H: render "Now Playing" synchronously
  S->>RS: ReadPacket (first)
  RS->>P: OpenInfo — this parser is really producing audio
  P->>P: write history row, correct "Now Playing" if the parser changed
  loop every 20ms, driven by disgo's sender
    S->>S: DAVE has no live epoch? hold, read nothing
    S->>RS: ReadPacket (Opus)
    S->>S: return the frame to ProvideOpusFrame
  end
  Note over S: a sender that stops pulling, or a conn the<br/>manager no longer knows, ends the track as<br/>ErrVoiceTransport
  RS-->>S: EOF
  P->>P: completion goroutine → PlayNext
  alt queue non-empty
    P->>RS: next track (new goroutine, run N+1)
    P->>P: emit StatusPlaying → watcher edits status message
  else queue empty
    P->>P: stop(true, run N) → ReleaseSink (leave VC)
    P->>P: watcher edits "Playback Finished"
  end

Key mechanics:

  • Queue — a plain []Track under p.mu. playNextMu serializes dequeue and open, so two tracks can never start at the same time.
  • Completion chain — the flow runs runPlayback → completion goroutine → PlayNext → startTrack → new runPlayback. Iteration happens through fresh goroutines rather than recursion, and queue-end disconnect has exactly one decision point: PlayNext returning ErrNoTracksInQueue leads straight to Stop(true).
  • Per-run ownership — each run is a *run with its own stop and done channels, track and flags, and its goroutines are handed it. A stale run’s goroutine can only end its own run, never a newer one; p.run == r answers whether it is still in charge (see ownership.md rule 5).
  • Discord sink installs an Opus frame provider on the disgo connection. Its sender pulls packets through ProvideOpusFrame. The provider holds frames while DAVE has no live epoch; transport liveness and stalled pulls have bounded checks, so a dead sender can surface as ErrVoiceTransport. Stop signals end the provider and release the waiting playback run. See ownership rules 8 and 9.
  • Pause/Resume — not supported, on purpose, since the sink owns the read loop. Commands that try get ErrPauseNotSupported back.

Status delivery (single-consumer contract)

Player.PlayerStatus is a buffered channel meant to have exactly one long-lived consumer per player, passed to music.Service as Hooks.Watch and spawned once when the scope’s player is created. On the bot side that’s voice.Service.WatchPlayerStatus; it only handles asynchronous transitions (auto-advance → edit “Now Playing”, natural queue end → “Playback Finished”). Anything interaction-driven — “Now Playing” after /play, “Track(s) Added” — is instead rendered synchronously by the handler, since it already knows what PlayNext returned. Don’t attach per-interaction listeners to the channel; competing receivers will end up stealing events from each other.

The guild status UI is a single message per guild (voice.Service.UpdatePlaybackStatus): created via interaction followup the first time, edited from then on — which incidentally is also why updates keep working past the 15-minute interaction-token expiry.

Failure handling

There are three distinct failure classes here, each with its own mechanism:

  1. Media failures, handled by stream.RecoveryStream on a per-track basis. Opening a parser proves nothing on its own — the ffmpeg-backed ones only spawn a process, so a CDN 403 surfaces on the first read — which is why Open logs stream_opening and the real stream_opened waits for the first packet. An instant fail — an error or EOF on that first read — moves on to the next parser in the track’s preference list.

    Mid-track, any read failure that looks early reopens the same parser at the current seek position, up to three attempts per parser. Not only io.EOF: a CDN resetting the connection arrives as a net error, and treating that as terminal was a gap that opened when passthrough removed the ffmpeg layer — with ffmpeg in front, a dropped connection reaches us as a closed pipe and reads as EOF. A natural EOF passes through untouched.

    What counts as “early” depends on whether the track has an end. With a known duration it means stopping before roughly 95% of it. Without one — internet radio, and YouTube live — there is no natural end to be near, so every stop is an interruption and the stream reconnects, at the live edge rather than at the old position, after a short backoff. The three-attempt budget is what keeps that bounded: a station that has genuinely gone off air is let go rather than retried forever.

  2. Voice transport failures, handled in player.runPlayback, up to three attempts. An ErrVoiceTransport from the sink triggers either hard mode (invalidate the sink, forcing a voice-channel rejoin) or soft mode (retry the stream first — governed by PLAYER_TRANSPORT_RECOVERY_MODE and PLAYER_TRANSPORT_SOFT_ATTEMPTS), then reopens media at the current position without touching the media retry budget.
  3. Session failures, handled in internal/discord. A gateway-silence watchdog (WS_SILENCE_TIMEOUT) marks the session unhealthy, and DISCORD_UNHEALTHY_MODE decides what happens next (restart-session, restart-voice, or ignore). main.go runs RunSession in a restart loop, and since music.Service and the voice service outlive individual sessions, queues and players survive reconnects — sinks just get invalidated and re-acquired.

    What makes that true rather than merely intended is that a sink provider holds nothing belonging to a session. A voice manager belongs to one bot.Client and closes over that client’s gateway; a DAVE registry is built fresh per session. A provider that kept either went on using a gateway that had been shut, which fails instantly and forever — and no invalidation helped, because what had gone stale was the thing that makes connections rather than a connection. Providers resolve both per acquisition, so the one memoized per guild is now safe to keep.

    disgo delivers heartbeat ACKs as events. The watchdog records them on arrival and reads their timestamps without taking the session’s write lock. The historical notes retain the previous library’s lock-related failure and why independent liveness checks matter.

    Teardown is still bounded and abandonable (closeWithin), for the reason that outlived the fork: it is the last thing RunSession does, so a step that never returns strands the restart loop and the bot a watchdog just correctly declared dead never comes back. Every step logs its duration under shutdown_phase.

On the user-facing side: synchronous failures get answered directly by the handler, as an ephemeral embed. Asynchronous failures — a track dying mid-play — travel through runPlayback → markPlaybackFailed → Options.OnPlaybackFailed → voice.Service.NotifyPlaybackFailed, which edits the guild status message, falling back to a public message in the last-used command channel if needed. reply.ClampEmbedText cuts the raw error text to something Discord will accept in an embed.

ProcessStream (the ffmpeg wrapper) converts a zero-byte EOF from a failed process into the real underlying error, so an instant ffmpeg failure — a 403, a bad URL — never gets mistaken for a clean track end. The transcode parsers build ffmpeg via ffmpeg.NewPCMCommand (or NewPCMCommandUA, which adds the extracting client’s User-Agent) and wrap its PCM output in ffmpeg.OpusReader, which encodes to Opus packets via opus.Encode. ffmpeg stderr is captured and classified (403/forbidden/conversion failures at Warn).

Discord command layer

Commands implement the melodix Handler interface and get registered through adapter.Register into keshon/command’s DefaultRegistry, wrapped in middleware for guild-only checks, per-guild disabled-command gating, permission checks, and invocation logging. Optional capabilities are discovered through interface assertion: SlashProvider, ContextMenuProvider, ComponentInteractionHandler.

Dispatch happens through onApplicationCommand, which routes slash and context-menu commands into queue and returns; message components are matched by a customID prefix convention (name, name:, name_) and go the same way. The body then runs on a worker, under the queue’s own cap (COMMAND_PARALLELISM limits how many run at once across all guilds).

That indirection is the point. disgo dispatches events synchronously — one goroutine reads the socket and calls every listener inline — so a command body running there held the socket unread for as long as it took, which for a hundred-item playlist is seconds. An interaction arriving in that window was acknowledged past Discord’s three-second deadline and the user saw “The application did not respond”. It also made COMMAND_PARALLELISM describe a property the runtime did not have: commands were already serial, so the semaphore never contended.

The gateway loop stays serial, because its ordering is load-bearing: Ready and GuildJoin both sync commands and must not interleave. One lane per guild, because a guild’s music is sequential — /play and /next at the same instant have no meaningful interleaving. slashsync’s per-guild lock became load-bearing in the same change rather than redundant: Ready syncs on the gateway goroutine while /commands enable syncs on a worker. Slash-command sync is handled by slashsync.Syncer, which diffs desired against existing per-guild commands by name, type, and fingerprint whenever INIT_SLASH_COMMANDS=true. And go run ./cmd/readme regenerates both command listings in README.md from the bot’s and the CLI’s catalogs — that’s a dev step, run from the repo root; neither binary writes files at runtime.

State & persistence

State lives in three places. In memory, per guild, and surviving reconnects: voice.Service holds players, sink providers, status-message IDs, and notify channels. In memory, per session: the Bot’s session context and exec guard, swapped atomically on each RunSession. And on disk: an embedded write-ahead-logged datastore (keshon/datastore) that owns the entire STORAGE_PATH directory (LOCK, wal.log, snapshot-*.json). Everything’s held in memory, and every commit is appended and fsynced before it’s acknowledged. The collections are guild_settings (disabled command groups), command_log (last 50 per guild), playback (last 750 per guild — /play <id> replays an entry without re-resolving it), and cache_entries (the global track-cache index). Per-guild collections are indexed by guild ID and keyed "<guildID>:<zero-padded id>", so reading an index returns a guild’s rows in chronological order; the IDs themselves come from the store’s persisted tx.NextID counters.

The storage directory is locked to a single process, so if the bot already holds the lock the CLI plays on without playback history and with an in-memory cache index, rather than refusing to start.

Only tracks that actually start playing get recorded, through the PlaybackRecorder hook.

Adding a new source or parser

See the extension checklists. Interface shapes are described under The three extension layers.

Known limitations

  • YouTube live broadcasts need yt-dlp, plus a JavaScript runtime — see the dependency note under Package map. They are HLS, and no other parser here can follow a playlist.
  • The track cache stores copyrighted audio to disk. It is opt-in, size-capped, and transient when CACHE_PERSISTENT=false, which makes it behave like a cache rather than an archive — but it remains a real tradeoff, not a footnote.
  • /play’s source and parser choice lists are maintained by hand (internal/discord/command/music/play/play.go) and can drift from the resolver and stream.registryEntries. Nothing checks this.
  • Pause and resume are not supported — the sink owns the read loop; see Playback pipeline. Commands that try get ErrPauseNotSupported.
  • One process per storage directory. The CLI runs without history and with an in-memory cache index when the bot already holds the lock, rather than refusing to start.
  • Convention checks reject violations across the source tree — see conventions.md.

Tried and rejected

Historical measurements and retired approaches are preserved in implementation notes.

Ownership

ownership.md defines lifecycle and concurrency contracts, names their enforcement and records coverage limits. Contributor practices are in conventions.md.

Testing & verification

Development commands and manual checks are in conventions.md.