# Agent Instructions Operating manual for AI agents and human contributors. `CLAUDE.md` imports this file — this is the single canonical copy; edit it here, never there. ## Collaboration - Do not just agree with the user by default. If a request would weaken the project, hurt maintainability, reduce correctness, or make the task outcome worse, push back clearly and suggest the better path. ## Git workflow - Before starting any PR-sized change: `git fetch origin`, make sure local `main` is even with `origin/main`, and create a feature branch from that fresh base. - Branch names follow `feat/…`, `fix/…`, `docs/…`, `chore/…`. `main` is the ongoing development branch; there is no permanent `dev`. A maintainer cuts one temporary `release/X.Y.Z` branch to freeze a candidate (see `docs/store-release.md`); changes still go through feature PRs. - Do not open PRs directly from `main`. If work accidentally happens on `main`, verify `main` is still even with `origin/main`, then move the work to a feature branch before committing. - Preserve user work. Do not revert or delete unrelated local changes or untracked files. - When the change is ready: commit on the feature branch, push it right away, and open a ready-for-review PR (never a draft) with `gh pr create` unless the user explicitly asks not to. - After opening a PR, monitor every required CI check to completion and report the outcome. **Merging is the maintainer's job — agents never merge PRs.** If a check fails, diagnose and fix it on the same branch, push the fix, and wait for the rerun to pass; a PR is ready to hand off only when every required check is green. - After a PR merges, do not reuse its branch — start the next change from a fresh `main`. - **External contributions are welcome and arrive as fork PRs.** A first-time contributor's checks sit in `action_required` until a maintainer approves the run (the button on the PR, or `gh api repos/windoze95/cantinarr/actions/runs//approve -X POST`), so a fork PR with no checks running is waiting on that, not broken. Fork PRs get a `pr-N` preview image like any other PR, just indirectly: the fork run's read-only token can't push to GHCR, so `docker.yml` uploads the built image as a run artifact and `pr-preview-publish.yml` (a `workflow_run` listener, so edits to it only take effect once merged to `main`) pushes the tag from base-repo context a couple of minutes after the docker check completes — it resolves the PR number via the API and pushes only that one tag, never a ref taken from the artifact. They hand off on the same all-green rule as any other PR; the maintainer merges. - Closing a PR removes its `pr-N` preview through `docker-cleanup.yml`. It uses `pull_request_target: closed` so fork PR cleanup has package write permission; the workflow runs from the base repository and must remain metadata-only, with no checkout or execution of PR code. ## Verification - Documentation changes: run `npm ci` and `npm run build` from `docs-site/` with Node 22.12 or newer. The build regenerates references from this checkout and checks links, fragments, metadata, settings coverage, tool coverage, configuration names, and the no-em-dash writing rule. CI runs this on every change because server and app reference changes also affect the public site. - Server changes: run `go vet ./...` and `go test ./...` from `server/`. - App changes: run `flutter analyze --no-fatal-infos` and `flutter test` from `app/`. Golden tests live in `app/test` (committed `goldens/*.png` beside their test file); regenerate them with `flutter test --update-goldens` from `app/`. - The Flutter SDK version is pinned in `app/.flutter-version`. CI, Docker, and store builds read that file; use the same SDK locally and update it with the lockfile when upgrading Flutter. - Flutter dependencies are pinned in `app/pubspec.lock`; commit intentional dependency changes there alongside `pubspec.yaml` changes. CI, Docker, `make`, and store builds install with `flutter pub get --enforce-lockfile`, then use `--no-pub` for analysis, tests, and builds so later commands cannot resolve different versions. Generate lockfile updates with the pinned Flutter SDK. - CI runs exactly those on every PR (Go tests with `-race`), plus a `CGO_ENABLED=0` server build and a `flutter build web --release`. A PR is not done if any of them fail. The same suite re-runs on every push to `main` and `release/**`, and on a weekly schedule (toolchain drift); a red `main` run is a defect to fix promptly. - **The exact checkout is the release gate.** `CI` records its tested SHA in a run artifact. Image, mobile, listing, and production workflows require a successful run proving that checkout through `require-ci-green.yml` / `scripts/release_control.py`. Branch pushes prove the actual merged tree; a stable tag reuses the candidate branch's CI evidence. A green PR head alone cannot prove a newer merge preview. - Automatic PR Docker previews and Android build-only checks are exempt from the gate. On-demand signed phone previews require green CI for the exact PR merge checkout, a maintainer's explicit reviewed head SHA, and the owner-only `mobile-preview` environment. Changed PR heads or merge SHAs invalidate the selection; never use `pull_request_target` to execute PR code with signing credentials. - Release tooling is tested in CI: Python routing/provenance tests, the actual iOS Fastfile's explicit build selection, Android Fastlane/Supply promotion tests, and actionlint workflow validation. - Apple TV helper changes run the locked Python worker tests in CI; both Dockerfiles smoke-test the helper and bundled dependency notices. Physical title handoffs still require the manual device cases. - Codex integration changes are also proved against the checksum-verified pinned app-server in CI. The Docker workflow builds and smoke-tests both Dockerfiles, including bundled license notices, before publishing the root image to GHCR. - iOS release builds happen only in CI (`testflight.yml`, auto-deploys on unfrozen `main` when iOS-relevant `app/**` paths change — web/android/desktop subdirs, `app/test/**`, `app/tool/**`, markdown files, and store-listing metadata/screenshots are excluded; listing copy syncs via `storelisting.yml` instead). Candidate iOS builds require manual dispatch from the active `release/X.Y.Z`; branch pushes hold mobile publishing. Don't assume a local iOS toolchain; when one isn't available, sanity-check Swift with `swiftc -parse` and let CI prove the build. - **An upload is not a release to testers.** The internal group auto-distributes, but external groups don't, so `testflight.yml` ends with a `distribute` job that adds the build to the public **Public Beta** group — eligible public-beta and candidate builds reach the existing TestFlight link. Phone previews use Internal Only export and skip external distribution. Adding a group, or changing which ones auto-receive builds, is a deliberate change to who gets shipped to; the details are in `docs/store-release.md`. - iOS signing is manual, via the `IOS_PROVISIONING_PROFILE_BASE64` secret. Changing app capabilities/entitlements invalidates the profile — regenerate it and update the secret. - Android release builds happen only in CI too (`playstore.yml`, builds a signed AAB on unfrozen `main` when Android-relevant `app/**` paths change — web/ios/desktop subdirs, `app/test/**`, `app/tool/**`, markdown files, and store-listing metadata are excluded — and publishes the same version to both Play **alpha** (closed testing) and **beta** (open testing) by default when `PLAY_SERVICE_ACCOUNT_JSON` is set). The AAB uploads once to `alpha`, then that exact version is copied to `beta` so existing closed testers keep receiving updates during the transition. Main dispatch can pick `both` (default) or only `beta`, `alpha`, or `internal`; candidates always reach alpha, beta, and the owner-only internal track from one AAB. Candidate Android builds require manual dispatch from the active release branch; branch pushes hold mobile publishing. Phone previews reach internal only. PRs that touch `app/android/**`, `app/.flutter-version`, `app/pubspec.yaml`, `app/pubspec.lock`, or the workflow get a build-only check (no upload), including the Fastlane publishing tests. No local Android SDK is assumed; let CI prove the build. - Android signing uses the `ANDROID_KEYSTORE_*` secrets (the upload keystore lives outside the repo). Store pipelines, secrets, and the one-time console setup are documented in `docs/store-release.md`. - Green merges publish `ghcr.io/windoze95/cantinarr:edge`. `latest` moves only on a stable release. Release branches publish multi-arch `X.Y.Z-rc.` candidates; `vX.Y.Z` tags promote the recorded tested digest to `X.Y.Z`, `X.Y`, and `latest`, then create the GitHub Release and attach its receipt. Linux release bundles are extracted from that image, not rebuilt. The app-wide update banner remains off. - Mention any tests or checks that could not be run. - Store screenshots intentionally show different service configurations. Keep Music hidden in the original screenshot profiles when refreshing them for UI changes; enable Music only for its separate screenshot. See `docs/store-release.md` before replacing a store slot. ## Releases & versioning - **One candidate, independently promotable components.** Server and mobile builds share a frozen source SHA for a coordinated release; marketing versions can diverge for independent patches. The About sheet shows both versions, and compatibility floors own supported skew. A release branch's name supplies the server version; `app/pubspec.yaml` supplies both mobile marketing versions. For the first 1.0 milestone, merge preparation on main without the native version bump, then put the 1.0.0 version/release-notes PR on `release/1.0.0`. This prevents preparation from starting 1.0 public betas. - **Freeze and record before promotion.** Keep at most one `release/X.Y.Z` branch. While it exists, it owns public mobile betas and store listings; main continues publishing `edge`. Candidate mobile builds and listing syncs require manual workflow dispatch. For a coordinated release, verify the final commit's server candidate before dispatching the matching apps. Record successful server/iOS/Android run IDs and actual device, upgrade, and compatibility results with `release-candidate.yml`. Candidate fixes invalidate older records; merge the release branch back to main through a PR using a merge commit so its tagged commit remains in main's ancestry. Follow the full procedure in `docs/store-release.md`. - **Cutting a stable server release**: `git tag vX.Y.Z && git push origin vX.Y.Z`. The exact SHA/version must already have a successful multi-arch Docker candidate, green CI, and a combined record selecting that exact candidate run; arbitrary green main commits cannot skip candidate creation. Promotion verifies the published digest, refuses to replace a numbered image, and refuses to move `latest` behind a newer release or image. Never move/delete a published tag; fix a bad release with a new patch. `-`-suffixed tags remain prereleases and never move `latest` or GitHub's `releases/latest`. - **Mobile production is explicit.** Dispatch `appstore-release.yml` or `playstore-release.yml` from main with a successful candidate build run ID. Selection verifies workflow, commit, version, and build number; never select "latest TestFlight". iOS submits that build for review with manual release; Android promotes that exact alpha version code to production. No production workflow rebuilds a binary. Store processing/review and actual device availability must be verified separately. - **Versions follow the released component.** Pre-1.0 uses minor bumps for features/breaking changes and patches for fixes; from 1.0 use major for breaks, minor for compatible features, patch for fixes. Release coherent changes rather than every merge. Keep tags immutable, archive the combined candidate record with the release, and delete the temporary release branch only after promotion and fix backports are complete. Dispatch main mobile/listing workflows once to resume immediately; otherwise they resume on the next relevant push. - **A release ripples into the app catalogs; Umbrel is the one manual step.** Every published image channel is multi-arch (amd64+arm64; only pr-N previews are amd64), so the catalog entries that track `latest` — Unraid CA, Portainer ([`lissy93/portainer-templates`](https://github.com/lissy93/portainer-templates)), CasaOS ([`IceWhaleTech/CasaOS-AppStore`](https://github.com/IceWhaleTech/CasaOS-AppStore)) — need nothing at release time. BigBear ([`bigbeartechworld/big-bear-universal-apps`](https://github.com/bigbeartechworld/big-bear-universal-apps), `apps/cantinarr/`) and TrueNAS ([`truenas/apps`](https://github.com/truenas/apps), `ix-dev/community/cantinarr/`) pin tags because those stores mandate it, and their renovate bots bump to each new tag on their own. Umbrel ([`getumbrel/umbrel-apps`](https://github.com/getumbrel/umbrel-apps), `cantinarr/`) mandates a `tag@digest` pin and has no bot: **every release owes it a small PR** bumping `version` and the image `tag@` (`docker buildx imagetools inspect ghcr.io/windoze95/cantinarr:` prints the digest); `releaseNotes` must stay empty while the app's initial listing PR is unmerged. - **Version-skew floors move only alongside the breaking change that forces them, in the same PR.** Raise `MinAppVersion` (`server/internal/version/version.go`) when the server stops supporting old apps; raise `minServerVersion` (`app/lib/core/utils/version_compat.dart`) when the app stops supporting old servers. Both are warn-only by design — never turn a floor violation into a hard block as a side effect of a bump; that would be its own deliberate change. - **Breaking-change ordering when the server drops old-app support**: ship the new app first — bump the pubspec version, let TestFlight/Play process it, dispatch the App Store submission, wait for Apple's approval, manually release it, and verify both stores actually offer the update — and only then cut the server release that raises `min_app_version`. Apple reviews every App Store submission regardless of bump size, so reversing the order strands store users behind "update this app" banners pointing at an update that doesn't exist yet. ## Manual test checklist - `docs/testing/` is the manual-only layer: cases that genuinely need a human or a real environment — live third-party truth, physical devices, store/release operations, chaos no suite can stage, and audits/exploratory sessions. Update it only when one of those surfaces changes; everything else is proved by the Go/Flutter suites and belongs there, not in the checklist. ## Architecture conventions - **The live DB schema is code, not SQL files.** It lives in `server/internal/db/db.go` (`initSQL` plus the in-code migration/`ALTER` list). Schema changes go there. - **Never trust a stored copy of *arr state.** Admins edit Radarr/Sonarr/Chaptarr/Lidarr directly, so any snapshot drifts. Availability and library state are computed live from the arrs; if you must cache, you must also have a freshness story (webhook invalidation, short TTL, or refetch-on-view). - **Automation access is explicit.** Regular users need `user_instance_grants` for every Radarr, Sonarr, Chaptarr, or Lidarr instance. Global and per-user defaults only choose routing among accessible instances; they never grant or restrict sibling access. Auto-assignment runs once at account creation, never on sign-in or identity linking, and excludes administrators. Administrators retain all-instance navigation and management; personal request pickers use their explicit assignments and assigned default. Discovery pages require personal assignments for administrators too, except on a new installation before its first instance is configured. Preserve that initial setup navigation; deleting instances or revoking assignments must not restore it. - **Media types vs service types.** `movie`/`tv`/`book`/`music` describe media; `radarr`/`sonarr`/`chaptarr`/`lidarr` describe services. Store and compare media types — don't substitute one for the other. - **Never silently dedupe or merge distinct records in search results.** Surface each record and let the user decide (e.g. two library entries for the same title are two results). - **Instance URLs resolve only from the server; clients must never dereference arr-origin URLs.** Cluster-internal names (`http://radarr:7878`) are a supported production configuration, so anything handed to a client must be client-reachable: use `images[].remoteUrl` (external CDN) for artwork, resolve arr-relative paths through `/api/instances/{id}/…`, and never surface an arr-origin absolute URL from a proxied body. Server-side, keep hosts out of error strings that can reach non-admins. - **Outbound traffic declares its class.** Every `http.Client` and the instance reverse proxy set `Transport` from `internal/httpx`: `External()` for internet hosts (TMDB, Trakt, hosted AI, plex.tv, GitHub, the push relay), which honours the admin's outbound proxy and, failing that, the standard proxy env vars; `Internal()` for arr instances, download clients, Plex Media Server, Jellyfin/Emby/Audiobookshelf, Tautulli/Tracearr, webhook installs, and the Local AI provider, which are never proxied -- not even by the env vars, so a LAN host never needs a `NO_PROXY` entry. The Local AI and external OIDC providers are the admin-typed endpoints an admin can move between classes: `local_openai_use_proxy` (off by default) declares it an internet host and switches that provider alone to `External()`; OIDC `use_proxy` (also off by default) makes the same explicit choice for discovery, JWKS, token exchange and UserInfo. Never infer a class from an address -- split-horizon DNS and Tailscale both defeat it, silently -- and never widen that exception to a host Cantinarr chose itself. The classification test in `internal/httpx` fails any `http.Client` literal that does not choose. A new outbound call picks a class on purpose: a cluster-internal host that rides the proxy is a bug, and so is an internet host that bypasses it. - **Secrets stay server-side and encrypted.** Instance API keys and credentials are AES-256-GCM encrypted at rest; never log them, return them in API responses, or write them into docs/examples. The one credential that is returned is the Seerr-compatible API key, because Cantinarr issues it for administrators to paste into another app rather than holding it for a third party; it goes only to `instances:manage` administrators through `/api/admin/seerr-api`, never appears in a log line, and the compat surface never echoes it. - **Requesters and admins speak different languages.** User-facing request UI uses requester vocabulary (Available / Requested / Downloading), not arr jargon (monitored, cutoff, unmet). - **An empty answer must say whether it is absence or blindness.** A read that finds nothing has said one of two completely different things — the thing is not there, or this code could not see it — and rendered the same way they are indistinguishable, so a reader stops looking. `internal/mcp/absence.go` holds the vocabulary: every empty result says what was actually searched and what an empty answer does and does not rule out. This is not style. Issue #814 went undiagnosed for thirteen days because three reads came back empty and were believed, when one had filtered a page of recent GLOBAL history for a title whose last event was two weeks old. - **A complaint about content arrives after the queue is empty.** "Wrong episode", "bad copy", "wrong audio" are only visible once a download finished and imported — sometimes weeks earlier — so queue state answers none of them and an empty queue is the expected reading, not a dead end. Diagnose those from the library and the arr's history, and repair them by acting on the imported file, not on a queue row. - **Kids accounts are enforced at the server's title chokepoints, never in the app.** A `user_content_policies` row makes an account a child; `internal/contentpolicy` owns the policy, the TMDB rating lookups, and the decision (`Allows` for TMDB payloads, `AllowsArrRecord` for Radarr/Sonarr records). Every payload that can name a movie or show passes one of these **after** any shared-cache read: `discover.serveTMDB`/`featured`/`cachedTrakt` (rows, search, details, people, genres, Trakt), `discover.buildDiscoverQuery` (limits pushed upstream), `mcp.ExecuteTool` (every discover tool), `request.CreateMediaRequest`/`GetUserStatus`/`GetRequests`, `proxy.InstanceProxy` (Radarr/Sonarr reads), `mediaaccess.Handler.Watch` (movie/TV media-server title links), `appletv.Handler` (adult-only TV control with live session/TV grants and canonical `mediaaccess.Handler.AuthorizeAppleTVTitle` checks before dispatch), `mediaaccess.Handler.Listen` (grant-only Chaptarr audio identity plus live Audiobookshelf library/tag/content access), `downloads.ActivityService` (content groups, children, artwork, and counts after shared source reads, with final grant/policy rechecks), `push.notifyNewContent`, and the AI `dynamicContext` line. Shared caches stay server-wide and unfiltered; the filter runs per user on the way out and fails closed: a title whose rating cannot be read is hidden and the response is a 502/503, never a thinner list that looks complete. Admins are never kids accounts (both directions are refused). Books and music carry no ratings and are grant-only for requesters, including kids. Admins may browse external catalogs without an instance; an explicit instance ID must still exist and match the service. Books use native Chaptarr discovery and requests under current instance authorization; the Hardcover trending feed (`bookdiscovery.TrendingHandler`) authorizes the Chaptarr instance before its shared per-instance cache and again before the response, and resolves only that instance's selected Hardcover API-token/OAuth connection when an upstream read is needed. OAuth credentials are encrypted separately from instance links; deliberate sharing serializes refreshes per connection. Connection changes use instance revisions, and cache invalidation must prevent stale in-flight reads from repopulating entries. Retired Open Library discovery/resolution routes return authenticated `catalog_retired` responses without provider traffic; unresolved saved source requests retain their approval requirements and stop matching. Music feeds, genres, album metadata, and artwork pass `musicdiscovery.Handler.authorizeMetadata` before the shared cache and before the response. The top-level `admin_catalog_browsing` config capability advertises only metadata access, never library or request authorization. A new surface that can name a title routes through one of these chokepoints or joins this list. The Seerr-compatible API (`internal/seerrcompat`, `/api/v1`) is on it as an admin-only surface: its only credential is the issued `X-Api-Key`, every call acts as the administrator who issued it, and no user session can reach it, so kids-account filtering does not apply there by construction; keep it that way, and if it ever gains a per-user mode it must join the chokepoints for real. Discord personal recipients pass `request.Service.DiscordAuthorize` at delivery through `discordnotify.prepareMessage`, after shared availability reads; it rechecks current roles, exact library grants, and movie/TV content policy. Channel publication is an explicit administrator-controlled disclosure. Availability receipts are notification history, never a substitute for live provider state. The admin request history endpoint (`request.Handler.ListHistory`, `/api/admin/requests/history`) is an aggregate title surface protected by `requests:manage` and a current administrator-role check. It exposes saved decisions across users, never library availability; keep it admin-only. The TV library detail endpoint (`request.TVLibraryDetail`) separates native browsing from request matching: every native season remains visible when a match is missing, paused, conflicting, or unreadable, with per-season request blockers instead of a whole-page failure. Kids accounts still require an identifiable, permitted source for every season before the combined parent's metadata is exposed. The endpoint rechecks the explicit library grant after provider reads. Library-numbered requests validate selected seasons before writes and use the same source-title approval, quota, and revision-guarded dispatch path. ## Documentation Docs are part of the change, not a follow-up. A feature is not merged-complete until the docs that describe that surface are true again. | Doc | Owns | |---|---| | `README.md` | Brief product overview, badges, screenshots, and links to setup and detailed docs | | `docs/configuration.md` | Service configuration and environment variable reference | | `server/README.md` | API route reference, MCP tool table (incl. the tool count), DB tables, WebSocket events, env vars, server package tree | | `app/README.md` | App features/screens, navigation map, project structure, key dependencies | | `docs-site/` | Public task guides and troubleshooting at docs.cantinarr.com; generated references stay owned by their original source documents | | `docs/books-setup.md` | End-to-end book automation setup: Chaptarr instance, explicit assignments, routing defaults, and new-user auto-assignment, instant updates, download path mappings | | `docs/music-setup.md` | End-to-end music automation setup: Lidarr instance, explicit assignments, routing defaults, and new-user auto-assignment, instant updates, how requests pick profiles, download path mappings | | `docs/store-release.md` | Branch/channel policy, candidate and production procedure, TestFlight/Play delivery, signing secrets, console setup | | [`windoze95/cantinarr-unraid`](https://github.com/windoze95/cantinarr-unraid) | Unraid Community Applications listing: published port, appdata path, the deployment env vars an admin is offered, and the listing copy | | `AGENTS.md` | Workflows, verification, conventions (this file) | - When a change touches a documented surface (new route, tool, env var, table, screen, workflow), update the owning doc **in the same PR**. The PR template's docs checklist is there to force the question. - Numbers drift fastest: tool counts, route lists, env-var tables, version floors (Go, Flutter/Dart). If you add one, update the count everywhere it appears (`grep -ri` for the old number). - **The Discord invite is a permanent link carried in six places.** It is created as a never-expiring invite and treated as unregenerable, the same way `docs/store-release.md` treats the TestFlight group link. Rotating it, or rebuilding the server, means updating all of: `README.md` (the header link bar and `## Community`), `.github/ISSUE_TEMPLATE/config.yml`, the Settings > About tile in `app/lib/features/settings/ui/settings_screen.dart`, both store descriptions under `app/ios/fastlane/metadata/` and `app/android/fastlane/metadata/`, and cantinarr.com in the separate site repo. `grep -rn discord.gg` finds every one. - Docs describe shipped reality — never "planned", "upcoming", or aspirational behavior. - Public help lives in `docs-site/src/content/docs/`. Follow its writing guide: outcome first, actual screen names, prerequisites before steps, and a useful next step when something fails. No em dashes, filler, or language that blames the reader. Generated paths (`reference/generated`, `integrations/guides`, `contributing/generated`) are ignored build output; edit their original sources. Publishing uses the existing Cloudflare credentials in `windoze95/cantinarr-site` and selects only the exact current main commit with green CI. See `docs-site/README.md`. - **The Unraid template is a deployment surface, not a doc that can lag — and it lives in another repo.** A stale template hands admins a container that is wrong on first boot, and Community Applications re-reads it on its own schedule, so there is no release to gate it. Changing the exposed port, the `/config` contract, or any env var an admin is expected to set means pushing the matching edit to [`windoze95/cantinarr-unraid`](https://github.com/windoze95/cantinarr-unraid) as part of the same change — note it in the PR body, since CI here cannot see that repo. It is split out because the CA scanner walks every `*.xml` in a repository and reports Flutter's Android resource files as `not_unraid_application`. - **The other app catalogs carry the same deployment contract, in repos we don't own.** The exposed port, the `/config` volume, and every env var an admin is offered also live in the Umbrel, TrueNAS, CasaOS, BigBear, and Portainer entries (repos linked under Releases & versioning). Changing that contract means a PR to each of them as part of the same change, noted in the PR body since CI cannot see those repos — an env-var rename reaches six template surfaces, not one. TrueNAS trap: user answers are stored keyed by question variable name, so rename a question's label and its emitted env var but never its `variable:` key, or existing installs silently lose their setting on update. Never comment on an external catalog PR without the maintainer of this repo approving the text first. - `CLAUDE.md` must remain a thin import of this file so every agent reads one playbook. If you change workflows here, check `CLAUDE.md` still just imports and points.