Skip to content

docs: cleanup rollup — prune plans, hand-tune CLAUDE.md, fact-check corrections - #675

Merged
Quick104 merged 9 commits into
mainfrom
docs/cleanup-rollup
Aug 18, 2026
Merged

docs: cleanup rollup — prune plans, hand-tune CLAUDE.md, fact-check corrections#675
Quick104 merged 9 commits into
mainfrom
docs/cleanup-rollup

Conversation

@Quick104

@Quick104 Quick104 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Single-pass rollup of three reviewed-together docs PRs, merged onto one branch at the maintainer's request:

All three branches merged without conflicts; combined tree verified: go build clean, gofmt clean, touched-package tests green, make verify-local-paths passes, AGENTS.md contains all sections from both #671 and #672.

Supersedes #671, #672, #674 (will be closed pointing here).

AI-use disclosure

Authored by Claude (Fable 5) via Claude Code at the maintainer's direction; details and evidence in the three superseded PRs.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Documentation
    • Added architecture guidance for invitations, onboarding, notifications, settings, observability, and downloads.
    • Expanded collection-template documentation with bundles, sources, validation, APIs, and application workflows.
    • Updated contributor, developer, security, storage, and wiki guidance.
    • Clarified documentation ownership, API evolution, plugin development, and WASM conversion constraints.
    • Removed retired, superseded, or internal-only design, audit, and planning documents.
    • Corrected the S3 configuration label from “Public Endpoint” to “Read Endpoint.”
  • Chores
    • Excluded temporary planning materials from version control.

Quick104 and others added 9 commits August 18, 2026 15:49
…architecture docs

The docs/superpowers/{plans,specs} tree had grown to 121 files (~3.4MB), all
describing work that has since shipped. Stale plans are a hazard for agentic
work — agents retrieve and trust them over the code. This removes the tree and
the other one-shot artifacts (retired security-report tombstones, old audit
notes, PR screenshots, shipped-feature mockup HTML), and gitignores
docs/superpowers/ so future plans stay out of git.

Durable content from the source-referenced specs was distilled first:
- docs/architecture/notifications.md (fanout eligibility, webhook trust/SSRF
  rules, HMAC signing, retry/auto-disable, Discord URL-leakage policy)
- docs/architecture/settings-contract.md (canonical settings contract,
  canonicalization/versioning invariants)
- docs/architecture/invitations-onboarding.md (invite token lifecycle,
  account/profile mapping, onboarding tour contract)
- downloads design notes folded into docs/downloads-api.md
- kindle/epub conversion design folded into tools/mobitool-wasm/README.md

All source comments that pointed at plan/spec files now point at the surviving
docs (three were already dangling). Also fixes the dead
docs/architecture/plugin-development.md link in DEVELOPMENT.md and updates
CLAUDE.md/CONTRIBUTING.md with the plans-are-ephemeral policy. Migration file
comments referencing old specs are left as-is (historical records; content
recoverable from git history).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Captures the project's settled direction for agents and contributors: the
cluster-first scale bet, open-platform stance, the core-vs-plugin rule, KISS
taste, and the current QA-era posture. Deliberately names no other projects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Borrowed from T3 Code's hand-written AGENTS.md structure: a glossary pinning
the ambiguous nouns (account/profile, item/file, section, node, session,
jellycompat/v1), a five-line dataflow map of scanner-to-playback, an explicit
completeness checklist for client-visible changes, focused-tests-while-
iterating guidance, and a severity marker on the irreversible gotchas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pre-lock, restructuring the API is in scope and larger coordinated sweeps are
preferred over incremental breaks with compat shims. The additive-only rules
become binding at v1 lock (1.0); until then they are the design target for new
endpoints. The identity section now defers to the v1 rules section instead of
stating its own strictness level.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A code-verified fact-check pass caught two errors in the freshly distilled
docs and dead links left by the prune: invitations-onboarding named the
SQLite profile_onboarding table as the completion store when the default
backend is Postgres (user_profile_onboarding) behind userstore.UserStore;
notifications claimed allowed_mentions always serializes an empty parse list
when the payload actually omits the field (embeds cannot ping because content
is never set); v1-scope linked four times into the deleted docs/superpowers
tree, now pointed at settings-contract.md / playback-protocol-v3.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A 29-doc fact-check pass (each claim chased to code, each finding
adversarially verified) found 8 docs with confirmed staleness. This applies
the corrections that belong on main:

- wiki/admin/collection-templates.md: rewritten against shipped code — the
  catalog has 100+ templates (doc listed ~34), five sources (doc listed
  three), the template-bundles feature was entirely undocumented, and the
  library picker is a multi-select. Now describes categories rather than
  enumerating templates so it cannot rot the same way again.
- architecture/observability.md: canonical component list gains access and
  diagnostics; the settings / webhook_sync api-handler exceptions are
  recorded as grandfathered.
- s3-storage-setup.md: 'Public Endpoint' renamed to the actual UI label
  'Read Endpoint' (s3.public_read_endpoint); the Cloudflare
  is_timed_hmac_valid_v0 guidance was verified correct against Cloudflare's
  docs and stands.
- design/2026-08-16-intro-skip-mode.md: status header corrected — shipped in
  silo-server (#660), not proposed.

Removed as expired rather than fixed:
- architecture/wholphin-endpoint-coverage.md: a 2026-06-09 endpoint audit
  whose 'missing/broken' claims were all fixed within a week (PRs #110,
  #164) and never updated; redoing the audit is the only honest fix.
- design/2026-07-09-streaming-write-deadline.md: marked 'planned' but
  shipped in the same commit that added it; the implementation and its
  tests (internal/httpstream/rolling_deadline.go) are the durable record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: db6c8071-2b3c-448d-bc7d-05ac59b27d52

📥 Commits

Reviewing files that changed from the base of the PR and between 8094513 and 18b8751.

⛔ Files ignored due to path filters (3)
  • docs/screenshots/pr-526-metadata-language-exception.png is excluded by !**/*.png
  • docs/screenshots/pr-526-metadata-language-iso-names.png is excluded by !**/*.png
  • docs/screenshots/pr-526-metadata-language-mobile.png is excluded by !**/*.png
📒 Files selected for processing (165)
  • .gitignore
  • AGENTS.md
  • CONTRIBUTING.md
  • DEVELOPMENT.md
  • contracts/settings/v1/manifest.schema.json
  • docs/SECURITY_AND_CODE_REVIEW.md
  • docs/architecture/invitations-onboarding.md
  • docs/architecture/notifications.md
  • docs/architecture/observability.md
  • docs/architecture/settings-contract.md
  • docs/architecture/v1-scope.md
  • docs/architecture/wholphin-endpoint-coverage.md
  • docs/design/2026-07-09-streaming-write-deadline.md
  • docs/design/2026-08-16-intro-skip-mode.md
  • docs/design/devices-redesign.html
  • docs/design/invite-onboarding.html
  • docs/downloads-api.md
  • docs/pr124-ebooks-review-hardening.md
  • docs/s3-storage-setup.md
  • docs/security_best_practices_report.md
  • docs/server-query-hardening-and-optimization-audit.md
  • docs/superpowers/notes/2026-05-27-abs-wire-shape-verification.md
  • docs/superpowers/plans/2026-05-24-audiobook-ui-redesign.md
  • docs/superpowers/plans/2026-05-24-audiobooks-absorption-1-discovery-schema.md
  • docs/superpowers/plans/2026-05-24-audiobooks-absorption-2-scanner.md
  • docs/superpowers/plans/2026-05-24-audiobooks-absorption-3-api-and-frontend.md
  • docs/superpowers/plans/2026-05-24-audiobooks-absorption-4-abs.md
  • docs/superpowers/plans/2026-05-24-audiobooks-absorption-5-podcasts.md
  • docs/superpowers/plans/2026-05-24-date-named-episodes.md
  • docs/superpowers/plans/2026-05-24-discover-studios-networks-genres.md
  • docs/superpowers/plans/2026-05-24-jellyfin-autoscan-scan-compat.md
  • docs/superpowers/plans/2026-05-25-provider-id-availability-backfill.md
  • docs/superpowers/plans/2026-05-25-search-request-section.md
  • docs/superpowers/plans/2026-05-26-abs-bookmarks-implementation.md
  • docs/superpowers/plans/2026-05-26-abs-collections-playlists-implementation.md
  • docs/superpowers/plans/2026-05-26-abs-phase-0-login-and-critical-fixes.md
  • docs/superpowers/plans/2026-05-26-abs-phase1-closeout-implementation.md
  • docs/superpowers/plans/2026-05-26-abs-smart-collections-implementation.md
  • docs/superpowers/plans/2026-05-26-metadata-curation-permission.md
  • docs/superpowers/plans/2026-05-26-tmdb-duplicate-tie-breaker.md
  • docs/superpowers/plans/2026-05-27-audiobook-catalog-filter-fields.md
  • docs/superpowers/plans/2026-05-27-audiobook-series-data-cleanup.md
  • docs/superpowers/plans/2026-05-27-catalog-facet-typeahead.md
  • docs/superpowers/plans/2026-05-27-collections-unify-1-schema.md
  • docs/superpowers/plans/2026-05-27-collections-unify-2-smartcoll-lift.md
  • docs/superpowers/plans/2026-05-27-collections-unify-3-abs-adapters.md
  • docs/superpowers/plans/2026-05-27-collections-unify-4-section-recipes.md
  • docs/superpowers/plans/2026-05-27-episode-catalog-performance.md
  • docs/superpowers/plans/2026-05-28-library-delete-resilience.md
  • docs/superpowers/plans/2026-05-29-calendar-presets.md
  • docs/superpowers/plans/2026-05-29-trending-discover-persistent-snapshot.md
  • docs/superpowers/plans/2026-06-01-request-multi-instance-arr-routing.md
  • docs/superpowers/plans/2026-06-02-autoscan-arr-polling.md
  • docs/superpowers/plans/2026-06-02-autoscan-host-backend.md
  • docs/superpowers/plans/2026-06-02-autoscan-rewrite-sync.md
  • docs/superpowers/plans/2026-06-02-autoscan-scan-source-sdk-capability.md
  • docs/superpowers/plans/2026-06-03-autoscan-arr-plugin.md
  • docs/superpowers/plans/2026-06-03-autoscan-host-ui.md
  • docs/superpowers/plans/2026-06-05-autoscan-source-labels.md
  • docs/superpowers/plans/2026-06-06-audiobooks-stacked-pr-split.md
  • docs/superpowers/plans/2026-06-06-marker-introdb-readpath-correctness.md
  • docs/superpowers/plans/2026-06-06-marker-sources-and-contribution-implementation.md
  • docs/superpowers/plans/2026-06-08-ebook-reader-full-parity.md
  • docs/superpowers/plans/2026-06-08-ebook-reader-shell-parity.md
  • docs/superpowers/plans/2026-06-08-encrypt-credentials-at-rest.md
  • docs/superpowers/plans/2026-06-09-literary-works.md
  • docs/superpowers/plans/2026-06-10-ai-translation-and-asr.md
  • docs/superpowers/plans/2026-06-14-collections-tab-server-section.md
  • docs/superpowers/plans/2026-06-18-ai-subtitle-live-translate-interleave.md
  • docs/superpowers/plans/2026-06-23-personal-collection-catalog-filter-standardization.md
  • docs/superpowers/plans/2026-06-25-hybrid-semantic-search-hardening.md
  • docs/superpowers/plans/2026-07-02-opa-policy-engine.md
  • docs/superpowers/plans/2026-07-02-opentelemetry-observability.md
  • docs/superpowers/plans/2026-07-03-section-fetch-performance.md
  • docs/superpowers/plans/2026-07-03-shared-list-cache.md
  • docs/superpowers/plans/2026-07-06-slow-endpoint-root-causes.md
  • docs/superpowers/plans/2026-07-09-autoscan-arr-webhook-intake-plan.md
  • docs/superpowers/plans/2026-07-09-plugin-hub-approved-community-catalog.md
  • docs/superpowers/plans/2026-07-12-playback-protocol-v3.md
  • docs/superpowers/plans/2026-07-19-ebook-enrichment-architecture.md
  • docs/superpowers/plans/2026-07-27-invitations-and-onboarding.md
  • docs/superpowers/plans/2026-07-28-transcode-resolution-clamp.md
  • docs/superpowers/plans/2026-07-30-playback-protocol-v3-neutral-contract.md
  • docs/superpowers/plans/2026-07-31-user-facing-device-settings.md
  • docs/superpowers/plans/artifacts/2026-05-24-audiobooks-discovery-findings.md
  • docs/superpowers/plans/audiobook-player-qol.md
  • docs/superpowers/plans/notifications/00-architecture-overview.md
  • docs/superpowers/plans/notifications/01-release-events-and-inbox.md
  • docs/superpowers/plans/notifications/02-apns-relay.md
  • docs/superpowers/plans/notifications/03-fcm-relay.md
  • docs/superpowers/plans/notifications/04-outbound-webhooks.md
  • docs/superpowers/plans/notifications/05-web-push.md
  • docs/superpowers/plans/notifications/06-v1.5-roadmap.md
  • docs/superpowers/plans/notifications/07-email-channel.md
  • docs/superpowers/plans/notifications/README.md
  • docs/superpowers/plans/notifications/design-decisions.html
  • docs/superpowers/plans/notifications/relay/00-relay-spec.md
  • docs/superpowers/plans/notifications/relay/01-implementation-plan.md
  • docs/superpowers/plans/notifications/relay/02-apns-fcm-2026-reference.md
  • docs/superpowers/plans/notifications/relay/03-decisions.md
  • docs/superpowers/plans/notifications/relay/README.md
  • docs/superpowers/plans/request-system-implementation.md
  • docs/superpowers/specs/2026-05-24-audiobook-ui-redesign-design.md
  • docs/superpowers/specs/2026-05-24-audiobooks-absorption-design.md
  • docs/superpowers/specs/2026-05-24-discover-studios-networks-genres-design.md
  • docs/superpowers/specs/2026-05-24-jellyfin-autoscan-scan-compat-design.md
  • docs/superpowers/specs/2026-05-25-request-search-all-design.md
  • docs/superpowers/specs/2026-05-25-search-request-section-design.md
  • docs/superpowers/specs/2026-05-26-abs-bookmarks-design.md
  • docs/superpowers/specs/2026-05-26-abs-collections-playlists-design.md
  • docs/superpowers/specs/2026-05-26-abs-implementation-fix-design.md
  • docs/superpowers/specs/2026-05-26-abs-phase1-closeout-design.md
  • docs/superpowers/specs/2026-05-26-abs-smart-collections-design.md
  • docs/superpowers/specs/2026-05-26-page-back-component-design.md
  • docs/superpowers/specs/2026-05-27-unified-audiobook-collections-design.md
  • docs/superpowers/specs/2026-05-28-library-delete-resilience-design.md
  • docs/superpowers/specs/2026-05-29-calendar-presets-design.md
  • docs/superpowers/specs/2026-05-29-trending-discover-persistent-snapshot-design.md
  • docs/superpowers/specs/2026-06-01-request-multi-instance-arr-routing-design.md
  • docs/superpowers/specs/2026-06-02-autoscan-arr-polling-design.md
  • docs/superpowers/specs/2026-06-02-autoscan-plugin-architecture-design.md
  • docs/superpowers/specs/2026-06-02-autoscan-rewrite-sync-design.md
  • docs/superpowers/specs/2026-06-05-autoscan-source-labels-design.md
  • docs/superpowers/specs/2026-06-06-marker-sources-and-contribution-design.md
  • docs/superpowers/specs/2026-06-09-literary-works-design.md
  • docs/superpowers/specs/2026-06-10-ai-translation-and-asr-design.md
  • docs/superpowers/specs/2026-06-10-on-view-description-translation-design.md
  • docs/superpowers/specs/2026-06-17-kindle-epub-conversion-design.md
  • docs/superpowers/specs/2026-06-18-offline-sync-mobile-design.md
  • docs/superpowers/specs/2026-06-23-personal-collection-catalog-filter-standardization-design.md
  • docs/superpowers/specs/2026-07-01-download-limits-restrictions-design.md
  • docs/superpowers/specs/2026-07-02-access-groups-design.md
  • docs/superpowers/specs/2026-07-02-opa-policy-engine-design.md
  • docs/superpowers/specs/2026-07-06-split-versions-reassign-design.md
  • docs/superpowers/specs/2026-07-08-s3-artwork-reconcile-design.md
  • docs/superpowers/specs/2026-07-09-autoscan-arr-webhook-intake-design.md
  • docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md
  • docs/superpowers/specs/2026-07-20-ebook-backfill-automation-design.md
  • docs/superpowers/specs/2026-07-25-plugin-watch-sync-provider-design.md
  • docs/superpowers/specs/2026-07-27-invitations-and-onboarding-design.md
  • docs/superpowers/specs/2026-07-28-transcode-resolution-clamp-design.md
  • docs/superpowers/specs/request-system.md
  • docs/wiki/admin/collection-templates.md
  • docs/wiki/index.md
  • internal/audiobooks/doc.go
  • internal/collections/templates/templates_test.go
  • internal/downloads/model.go
  • internal/ebookconvert/converter.go
  • internal/ebookconvert/errors.go
  • internal/invitations/repository.go
  • internal/metadata/nfo/nfo.go
  • internal/notifications/discord_embed_meta.go
  • internal/notifications/fanout_logic.go
  • internal/notifications/release_types.go
  • internal/notifications/request_notifier.go
  • internal/notifications/webhook_guard.go
  • internal/notifications/webhook_payload_discord.go
  • internal/notifications/webhook_payload_generic.go
  • internal/notifications/webhook_sender.go
  • internal/onboarding/onboarding.go
  • internal/partman/partman.go
  • internal/settingscontract/contract.go
  • internal/telemetry/config.go
  • tools/mobitool-wasm/Dockerfile
  • tools/mobitool-wasm/README.md

📝 Walkthrough

Walkthrough

This PR reorganizes repository documentation, adds architecture guidance for several subsystems, updates operational documentation, redirects stale references, and removes retired plans, specifications, audits, and design mockups.

Changes

Documentation and Repository Guidance

Layer / File(s) Summary
Repository governance and documentation lifecycle
.gitignore, AGENTS.md, CONTRIBUTING.md, DEVELOPMENT.md, docs/wiki/index.md, docs/design/2026-08-16-intro-skip-mode.md
Repository rules now define durable architecture documentation, ephemeral plans, plugin ownership, focused testing, client coordination, and the pre-lock and post-lock API contract.
Architecture contracts and system behavior
contracts/settings/v1/manifest.schema.json, docs/architecture/*, docs/downloads-api.md, docs/architecture/v1-scope.md, internal/invitations/*, internal/notifications/*, internal/settingscontract/*, internal/telemetry/*, internal/downloads/*, internal/onboarding/*
New and updated architecture documents describe invitations, onboarding, notifications, observability, settings, and downloads behavior. Related schema and package references now point to the current documents.
Admin and runtime documentation
docs/wiki/admin/collection-templates.md, docs/s3-storage-setup.md, tools/mobitool-wasm/*, internal/ebookconvert/*, internal/audiobooks/*, internal/metadata/nfo/*, internal/collections/templates/*, internal/partman/*
Collection-template bundles, S3 read endpoints, WASM conversion constraints, and related implementation references are documented.
Retired documentation removal
docs/SECURITY_AND_CODE_REVIEW.md, docs/design/*, docs/pr124-ebooks-review-hardening.md, docs/security_best_practices_report.md, docs/server-query-hardening-and-optimization-audit.md, docs/superpowers/*, docs/architecture/wholphin-endpoint-coverage.md
Retired security reports, audits, endpoint coverage material, design mockups, and implementation plans are removed.

Estimated code review effort: 3 (Moderate) | ~20 minutes

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/cleanup-rollup

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 18b8751a75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +20 to +21
server user's permissions, so filesystem isolation relies on running the
server as a constrained, non-root user (the container already does). Never

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Stop claiming the runtime container is non-root

The production Dockerfile never declares USER, and docker-compose.yml supplies no user override, so the provided Silo container runs as root. In the writable-mount traversal scenario described immediately above, the claimed privilege boundary is therefore absent and filesystem access occurs with root permissions; either make the runtime image non-root or document the required deployment configuration instead of saying the container already provides it.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

Comment on lines +24 to +26
- The canonical bytes of the manifest are its RFC 8785 (JCS) canonicalization.
The manifest endpoint's `ETag` is the SHA-256 of those bytes, and
generated-code reproducibility is defined over the same bytes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the actual public ETag input

A client that follows this text and hashes CanonicalBytes() cannot reproduce the endpoint's ETag: HandleGetContract uses PublicETag(), whose input strips maintainer-only notes and whose digest also incorporates every canonicalized value schema via digestWithSchemas. This matters for generated-client reproducibility and contract caching, so the document should describe the public projection plus schema inputs rather than SHA-256 of the manifest bytes alone.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

Comment thread AGENTS.md
Comment on lines +103 to 105
disagrees with the code is wrong. Any committed doc must not contain local absolute
filesystem paths or transient worktree IDs — use repository-relative paths and wording like
"Commands assume the repository root is the cwd." `make verify-local-paths` enforces this.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Run the path check over all committed documentation

After this commit deletes and ignores docs/superpowers/, scripts/check-local-path-leaks.sh still passes only docs/superpowers/specs and docs/superpowers/plans to both git grep calls. Those paths now contain no tracked files, so make verify-local-paths succeeds without inspecting any surviving committed documentation, including the newly designated docs/architecture/ destination; expand the check to the committed docs tree (with any intentional exclusions) before claiming this invariant is enforced.

AGENTS.md reference: AGENTS.md:L103-L105

Useful? React with 👍 / 👎.

Comment on lines +56 to +63
- **Accept is race-safe.** `Repository.Accept` claims the row with a single
`UPDATE ... WHERE accepted_at IS NULL AND revoked_at IS NULL AND
expires_at > now()`; of two concurrent accepts exactly one matches. The
loser's account creation is independently blocked by the `users` unique
constraints. Account-plus-default-profile creation goes through
`auth.AccountProvisioner.CreateAccount` (rollback on profile failure), and
a successful accept ends with a normal login, returning the same token pair
shape as signup.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Claim the invitation before provisioning the account

This is not race-safe against revocation or supersession: Service.Accept calls claimable(), then commits AccountProvisioner.CreateAccount, and only afterward calls Repository.Accept. If an admin revokes or replaces the invitation between those operations, the final UPDATE fails but the user and default profile remain, with the password and access grants from the supposedly invalidated link; the user can subsequently log in normally. The invitation claim and account creation need one atomic boundary or compensating deletion before this invariant can be documented.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

Comment on lines +32 to +36
Token handling mirrors the email-verification flow: 32 random bytes,
`base64.RawURLEncoding` in the claim URL (`/invite/<token>`), SHA-256 hex
stored at rest as `token_hash`. The raw token exists in the sent email (or in
the create response when mail is unconfigured) and nowhere else — a database
dump yields no usable links.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Account for tokens returned after successful email delivery

The raw token is present in the admin create/resend response even when mail is configured and successfully sent: Service.Send always fills ClaimURL, and buildSendResponse always serializes it. Describing the response copy as limited to the unconfigured-mail case understates where this bearer credential appears and can cause API clients or response logging to handle it as non-sensitive; document that claim_url is always returned, or conditionally omit it in the implementation.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

Comment on lines +86 to +89
RFC 1918, link-local, CGNAT, TEST-NETs, benchmarking, multicast, reserved)
and the IPv6 equivalents (unspecified, loopback, ULA, link-local,
documentation, NAT64). IPv4-mapped IPv6 addresses are unwrapped before
checking so `::ffff:127.0.0.1` cannot bypass the IPv4 entries.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Deny the local-use NAT64 prefix

On deployments routing the RFC 8215 local-use NAT64 prefix, the stated NAT64 protection is incomplete: webhookDeniedNetworks blocks only 64:ff9b::/96, not 64:ff9b:1::/48. A webhook URL using a synthesized address under the latter prefix therefore passes both registration and connect-time checks, and the network can translate it to an otherwise-denied internal IPv4 destination. Add the local-use prefix to the shared deny set before treating NAT64 as covered.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

webhook IDs, timestamp, `version`, `test` flag, profile ID, delivery type,
reason flags, and the series/episode (or request) content blocks.

Each request is signed with the webhook's per-destination 32-byte secret:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Specify the encoded webhook HMAC key

The API does not actually use a 32-byte value as the HMAC key: newSigningSecret generates 32 random bytes, returns their padded base64 representation, and SignGenericWebhook signs with the bytes of that encoded string without decoding it. A receiver following this description can reasonably base64-decode the returned secret to 32 bytes and will then reject every delivery signature; document that the returned base64 text must be used verbatim, or decode it consistently on the sender side.

AGENTS.md reference: AGENTS.md:L102-L103

Useful? React with 👍 / 👎.

Comment thread .gitignore
Comment on lines +52 to +54
# Agent-authored plans/specs are ephemeral scratch: written here while working,
# distilled into docs/architecture/ (or the PR description) before merge.
docs/superpowers/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retarget references before deleting the plan tree

A repo-wide search after this deletion still finds 13 tracked migration files pointing at now-nonexistent docs/superpowers/... documents, including the downloads, notification-webhook, settings-contract, and invitations migrations. Those comments are the migration's durable rationale breadcrumbs, but a fresh checkout can no longer follow them even where this commit added replacement architecture documents; update them to the surviving docs/architecture/ or API docs, or remove the dead references as part of pruning the tree.

AGENTS.md reference: AGENTS.md:L98-L103

Useful? React with 👍 / 👎.

Quick104 added a commit that referenced this pull request Aug 23, 2026
PR #675 pruned docs/superpowers and the shipped design artifacts, distilling the
durable content into docs/architecture first. The streaming write-deadline
document was deleted on main under that rollup while this branch was extending
its writer-chain conformance section, which is the whole of the conflict between
the two.

This carries the durable half forward on main's own pattern: the invariants a
future change has to respect — the rolling-deadline contract, why slice size is a
correctness constraint rather than a knob, the two rules every ResponseWriter
wrapper on a media route must follow, the one-limiter sendfile trap, why chi's
compressor is bypassed rather than repaired, and how conformance is actually
verified. The one-shot half — the 2026-07-09 debugging session, the per-file
application table, the rollout plan, the silo-apple follow-up list — goes with
the deletion.

Also records the two rules this branch's review turned up: the bump throttle
belongs to Write and never to a ReadFrom slice, and the proxy egress meter has
the same shape of constraint at a different value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Quick104 added a commit that referenced this pull request Aug 23, 2026
Resolves the branch's only conflict with main and picks up the fix for the
frontend typecheck failure that has been failing CI on this PR since 18 August
(main's d285d0c; the failing file does not exist on this branch — CI builds the
merge commit).

Three resolutions:

- docs/design/2026-07-09-streaming-write-deadline.md: accept main's deletion.
  #675 pruned the shipped design artifacts after distilling their durable content
  into docs/architecture; a1ba4d6 does that half for this document, and the
  stream-telemetry design's cross-reference now points there.
- docs/feature-changelog.md: ordering only. Both sides kept, newest first.
- internal/api/testdata/media_routes.txt: main added GET /api/v1/api-keys/scopes,
  which the per-family manifest guard correctly refused to accept unclassified.
  Verified against the handler — it writes a small JSON scope catalog and serves
  no media bytes — and pinned as non-media. This is the guard doing its job on a
  route that arrived from outside the branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JonahMMay added a commit to Prairie-Server/prairie-server that referenced this pull request Aug 28, 2026
* fix(watchsync): redact undeclared connection secrets

Connection config fields absent from a schema's declared sets are classified
as secrets fail-closed, but the fault-redaction list collected only declared
secret fields. A plugin echoing such a value in a fault returned it in the
clear. Redact every flattened secret value instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(watchsync): record per-connection provider setup

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(deps): drop stale plugin SDK checksums

go mod tidy removes the v0.13.1 hash pair left behind by the v0.13.2 bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(audiobooks): gate enrichment on identity, not cover art

The audiobook enrichment sweep had been permanently idle. Eligibility
required an empty poster_path, but the scanner extracts embedded cover
art from audiobook files, so every item had a poster before enrichment
ever looked at it. Cover presence says nothing about whether an item was
identified, and using it as the "already enriched" proxy meant the
predicate matched nothing.

On production this selected 0 rows while 5,712 audiobooks held no
provider ID at all -- 5,710 of them carrying a scanner-supplied poster.
The metadata providers had enriched 42 items in total; the 235,780 ASINs
in the library came from file scan, not from a provider. The 5-minute
trigger kept firing and finding nothing, so the gap could never drain.

Key both claimBatch and HasPendingItems on the absence of any row in
media_item_provider_ids instead. Sized against production this makes
5,682 items eligible rather than the 240,857 that dropping the poster
clause alone would have queued against Audible.

last_refreshed IS NULL is retained as the retry bound, and it is load
bearing: enrichItem stamps it on a clean no-match but deliberately
withholds it on provider error, so unmatchable items are attempted once
while transient failures still come back.

Adds DB-backed tests for both predicates, including the exact row the
regression hid (cover art present, no identity). Both fail against the
old predicate and pass against the new one. Neither query had any test
coverage before, which is how this survived.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(metadata): require a credible title match before accepting a provider result

Enrichment took results[0] from every search provider unconditionally.
Search backends rank by their own relevance and answer nearly every
query with something, so a provider's best guess became the item's
permanent identity -- and enrichment stamps last_refreshed on success
and never revisits, so a wrong acceptance is a permanent mislabel:
another book's cover, overview and narrator.

Measured rather than assumed. Twenty unidentified production audiobooks
were queried against the iTunes audiobook search: 19 returned a result
and roughly a quarter of those top hits were wrong -- either a different
volume of the right series ("The OP MC 8: God of Winning" answered by
"God of Winning: The OP MC, Book 1") or an unrelated title sharing one
common word ("Star Force Origins" answered by "The Achilles Trap").

Adds metadata.BestMatch, which scores candidates against the title we
hold on disk and returns nothing when none is credible -- callers treat
that as a no-match instead of falling back to the top row. Scoring is
word-set overlap (Dice) because the differences that matter are whole
words added or dropped: an author prefix, a series parenthetical, an
"(Unabridged)" suffix. A volume disagreement is fatal regardless of
overlap, since same-series wrong-volume pairs share nearly every word.

Wired into all three call sites that had the flaw: audiobooks, ebooks
and manga. Ebooks scores against item.Title rather than the outgoing
query, which is deliberately cleaned before it is sent.

Calibrated on the real probe pairs, kept as the test fixtures: correct
matches score >= 0.88, wrong ones <= 0.44, threshold 0.50. A test
asserts the two populations stay separated so a future tweak that
narrows the gap fails here rather than in production.

Three ebook fixtures needed a title on their SearchResult. They were
passing a result with no name at all, which the gate rejects; one of
them (SkipsProviderIDOwnedByAnotherItem) would otherwise have kept
passing for the wrong reason, never reaching the ownership check it
exists to cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(metadata): drop stopwords when scoring title similarity

Validating the gate against the full 20-title production probe caught a
false accept it should have blocked. "All the Lies 1-3 - All the Lies:
The Complete Trilogy" was matched to "The Sentinel: The Complete Jane
Harper Trilogy: The Jane Harper Trilogy, Books 1-3" at 0.56, over the
0.50 threshold.

Both are "Books 1-3" boxed sets of unrelated series, and the score came
almost entirely from words that carry no identity: "the" three times,
plus "complete", "trilogy" and the 1 and 3 of the volume range. Neither
title's actual subject contributed. Excluding a small stopword set takes
the pair to 0.42, where it belongs, and leaves every correct match in
the sample above threshold.

The exclusion is skipped when it would leave fewer than two words to
compare, so titles that are mostly stopwords ("A Man in Full") still
have something to match on rather than scoring 0 against everything.

Rescored against all 20 probe pairs: 12 accepted, 7 rejected, 1 had no
answer. Every rejection is a genuine mismatch -- two wrong-volume pairs
of the right series, two unrelated titles, this boxed set, a bare common
noun, and a same-series different-book pair. Blind results[0] acceptance
would have taken 19 of these, roughly 6 of them wrong.

Fixtures now carry the untruncated provider strings, since the truncated
ones scored differently from what production actually returns -- which
is why the first version of this test passed while the real pair failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(ebooks): search the bare title and disambiguate volumes after the search

cleanEbookSearchTitle unwrapped a trailing series marker -- brackets
removed, words kept -- so "Icarus (Realm Book 3)" went out as "Icarus
Realm Book 3". The reasoning was that the volume number is what tells
sibling volumes apart, and dropping it would make every entry in a
series search as the bare series name and collapse onto one work.

That held only while enrichment accepted results[0] blindly. It is no
longer true: metadata.BestMatch scores candidates against the raw
item.Title, which still carries "(Realm Book 3)", and treats a volume
disagreement as fatal. The disambiguator never needed to be in the
query -- it needed to be in the check, and now it is.

Meanwhile the unwrapped form was not disambiguating the search, it was
breaking it. "Realm Book 3" is retail furniture that no provider
catalogue indexes, so the query matched nothing at all.

Measured on 40 parked no_match ebooks that carry a series parenthetical,
with both query forms produced by this function and both result sets
scored by the real gate:

  what the server sends today : Open Library answered  3, accepted  0
  after this change           : Open Library answered 29, accepted 14

14 recovered, 0 lost. The gap between 29 answered and 14 accepted is the
scoring gate rejecting results that came back but are not the book --
15 wrong identities that blind results[0] would have stamped
permanently.

Scope, so the number is not read as more than it is: 23,802 of the
110,556 parked no_match ebooks (21.5%) carry a trailing series
parenthetical, so this reaches about a fifth of the backlog. The other
78.5% fail for reasons this does not touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(metadata): make title normalisation Unicode-aware

Found reviewing this branch's own diff. normaliseTitle stripped via
[^a-z0-9] after lowercasing, which is ASCII-only. Every non-Latin title
reduced to the empty string, so TitleScore returned 0 and BestMatch
rejected it -- including against an identical title. "進撃の巨人" scored
0.00 against itself. Accented Latin fared little better:
"Blåbærsyltetøy" became "bl b rsyltet y".

That would have been a hard regression for non-English content, and a
quiet one: those items previously matched by accident, because
results[0] was accepted without any check at all. Manga is the obvious
exposure, but the library carries Danish and Norwegian ebooks too.

Strips [^\p{L}\p{N}]+ instead, so letters and digits in any script
survive. Tests cover Japanese, Russian and Danish titles matching
themselves and rejecting different titles in the same script.

For scripts that do not space their words, a whole title normalises to
one token, so Dice gives 1 on an exact match and 0 otherwise, with
containment carrying near-misses. Coarse, but correct, and strictly
better than what it replaces. minContainmentLen stays byte-measured,
now documented as deliberate: for ASCII it is the character count the
threshold was calibrated on, and for multi-byte scripts it is more
permissive, which is right because a four-character CJK title is
specific in a way that "Bitcoin" is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(metadata): fold numerals, break ties on year, and stop cross-provider ID mixing

Follow-ups to the match gate, each closing a hole the first pass left.

Numeral folding. Providers and rippers disagree freely on form:
"Slaughterhouse 5" against "Slaughterhouse-Five" scored exactly at the
threshold and matched only by luck, and a "Part II" volume never agreed
with "Part 2" -- which the volume rule then read as a disagreement and
rejected outright. Spelled and Roman numerals now fold to digits before
scoring, capped at 30 so single letters like "X" and "I" stay letters
("Malcolm X" must not become "Malcolm 10").

Year as a tiebreak, never a gate. For books a year gap is weak evidence:
an audiobook edition of a 1994 novel is routinely dated by its recording
decades later, so rejecting on it would discard correct matches
wholesale. It only decides between candidates already tied on title.

Cross-provider agreement. Each provider was scored independently and
every accepted match was merged into one ID map, so two providers could
each clear the bar while naming different books and leave the item
holding IDs for two works -- worse than either answer alone, because
afterwards the wrong ID is indistinguishable from the right one. The
first accepted title is now the reference and later providers must agree
with it.

Ownership guard for audiobooks and manga. FindContentIDByProviderIDs
appeared three times in the ebook enricher and nowhere in the other two,
so sibling volumes resolving to one provider work all claimed the same
ID with nothing noticing -- the exact collapse the ebook code guards
against, and manga is the most exposed, with series running to dozens of
near-identical titles. Ported including the "don't claim an ID we
couldn't verify is free" path that retries rather than stamping
terminally.

Rejections now log at Info. During a backlog drain the rejection rate is
what separates "threshold too strict" from "providers answering badly",
and that cannot be read from a log level nobody enables.

The threshold is overridable via SILO_METADATA_MATCH_MIN_SCORE so it can
be retuned against a live library without a rebuild; out-of-range values
are ignored rather than obeyed, since a typo'd 0 would accept everything.

Acceptance is now strictly above the threshold rather than at it: a
two-word title sharing one word with a two-word candidate scores exactly
0.5, which is the weakest possible evidence, not a match. Nothing correct
is lost -- the worst true match in the calibration sample scores 0.86.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(metadata): verify the author after fetching, not just the title

Title scoring cannot separate two different books that share a title,
and for books that is not a rare edge -- generic titles recur constantly
across authors, so the gate would accept the wrong one on nothing more
than a title agreement.

The obvious fix is unavailable: the plugin contract's SearchResult
carries title, year, overview, image and provider IDs, and no author at
all. Checking it at search time would mean changing the SDK proto and
every plugin implementing it.

It is available one step later. Enrichment already calls GetMetadata on
the accepted match, and MetadataResult.People carries the credits, so
the author is verified after the fetch: a positive contradiction is
recorded as a no-match rather than written. Wired into all three
enrichers.

Absence is never disagreement. An item with no author, or a provider
returning no credits, passes -- most of this library is missing one or
the other, and treating that as a conflict would reject far more good
matches than bad ones. Only a name that actively contradicts rejects.

Name comparison allows for how the same person gets credited: surname
plus first initial (which survives middle names and
initials-vs-full-first-name), the "King, Stephen" reversal, and one form
contained in another ("Iain Banks" within "Iain M. Banks"). Non-author
credits are ignored, so a narrator neither confirms nor contradicts
authorship.

Diacritics ARE folded for names and deliberately are NOT for titles.
Providers transliterate people inconsistently -- "Émile Zola" and "Emile
Zola" are one person, and without folding they disagreed on their first
initial and the match was rejected. Titles are different: "Blåbær" and
"Blabaer" are not reliably the same work, and folding there would erase
a real distinction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* feat(audiobooks): give enrichment its own state table

Audiobook enrichment recorded its outcome in exactly one place:
media_items.last_refreshed. That stamp had to mean "matched",
"genuinely unmatchable" and "the provider was down that minute" all at
once, so a bad afternoon on a provider burned the item exactly as
terminally as a real no-match, with no attempt count, no error class and
no backoff. Ebooks have ebook_enrichment_state and manga has
manga_enrichment_state; audiobooks had nothing.

The new table sits deliberately between those two. It is not the full
ebook lease queue: audiobook sweeps run from a single task-manager
goroutine and claimBatch takes no row locks, so claim_token/lease_until
would be machinery with nothing to coordinate. It is more than the manga
table, which counts failures and nothing else.

last_refreshed stays authoritative for eligibility, so the migration
changes no behaviour by itself. What changes is that the sweep now skips
items parked for a later retry, and records why each item is where it
is.

Failures are classified, because the distinction is the one the ebook
backlog lost: 90,721 rows there carry outcome='no_match' with attempts=0
and an empty error class, which is why a rate-limited sweep on
2026-07-20 is indistinguishable from 90,721 genuine misses. Here a
failure records a class and parks a retry WITHOUT setting an outcome --
it has not reached a terminal state. Rate limiting backs off hardest
(retrying into a closed window is what turns a throttle into a backlog)
and permanent failures park far out rather than never, since "permanent"
is a classification and classifications are sometimes wrong.

attempts always increments, including on terminal rows, so "never tried"
and "tried and gave up" stay distinguishable.

Bookkeeping never fails the enrichment it describes: a state write error
is logged and swallowed, since last_refreshed remains the authoritative
signal and losing a state row costs reporting detail, not correctness.

Seven DB-backed tests, run against a migrated throwaway Postgres rather
than assumed: the two covering the parked-retry predicate fail with the
predicate removed and pass with it, so they guard something real.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(metadata): close four review findings on the match gate

All four verified by reproduction before fixing; none were false
positives.

NFC-normalise before tokenising. A decomposed accent (e + U+0301) is a
combining mark to the punctuation strip and vanished, while the composed
spelling kept its letter, so two byte-level spellings of "Café" scored 0
against each other and an identical title was rejected.

A wrong-volume primary can no longer be rescued by its alias. Aliases
are often the bare series name; "Dungeon In My Closet, Book 5" was
accepted for a volume-2 item through its generic "Dungeon In My Closet"
alias, persisting IDs for the wrong book. A volume stated on the primary
title that contradicts the item's now disqualifies the whole result.

The ebook series-noise test requires volume syntax, not a marker word.
Bare "book" matched anywhere, so "(The Book Thief)" -- a title, not
furniture -- was stripped from the query. The parenthetical is now only
dropped for "Book 4" / "#3" / bare-number / bare-year shapes.

The scheduler-mirror test asserts its own fixture was claimed rather
than that the claimed set is non-empty, which unrelated rows in a shared
test database could satisfy.

Also consolidates the two identical providerIDOwnerLookup interfaces
(ebooks, manga) onto metadata.ProviderIDOwnerLookup per the repo rule
against duplicated contracts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(audiobooks): make failure recording atomic and UTF-8 safe

Two review findings on the state store, both real.

RecordFailure was two round trips: an upsert incrementing attempts, then
an update parking next_attempt_at. Anything landing between them -- a
cancelled context, a dropped connection, a concurrent RecordOutcome on
the same row -- left the item with an incremented attempts count but no
backoff at all, immediately re-claimable against the very provider that
just failed; or let a terminal outcome be resurrected as a parked retry.
Now a single upsert computes min(step * attempts, cap) on the
post-increment attempts value inside the statement, so there is no
partial-write window rather than a transaction hiding one. retryAfterFor
remains as the Go mirror of the same math, built on shared
backoffParams so the two cannot drift.

The cause truncation cut at a byte index, which can split a multi-byte
rune; Postgres rejects invalid UTF-8, which would have silently failed
the entire failure/backoff write for that call -- the exact no-backoff
state the atomicity fix removes. Truncation now passes through
strings.ToValidUTF8, with a DB-backed regression test whose 500-byte
boundary lands mid-rune.

Also reverts four pre-existing files (abs/, podcastfeed/) that an
overly broad gofmt -w swept into the branch; they are not part of this
change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Db4dSxN9tH8yN7uUP549tK

* fix(metadata): harden book enrichment admission

Part of #510

* fix(ebooks): count real no_match cycles and escalate their retry horizon

Complete() zeroed attempts on every outcome, so a row bulk-stamped
no_match without a lookup was indistinguishable from one that failed
five real attempts, and every no_match returned on the same flat 30-day
horizon forever — permanently unmatchable titles (cover-scan artifacts,
bare file numbers) consumed the same daily recycler quota as books that
were never actually tried.

- failures now counts consecutive no_match completions (success resets)
- the no_match horizon doubles per prior failure, capped at 8x:
  30d, 60d, 120d, 240d
- retail edition suffixes are stripped from search queries — anything
  ending in Edition(s)/Classics, Kindle Single, A Novel — the same
  reasoning as the series-marker rule: providers index the work, not
  the storefront packaging. (Illustrated)/(Annotated) still survive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(access): implement onboarding methods on the resolver stub store

Upstream #501 added GetOnboardingState/UpsertOnboardingState to
userstore.UserStore without extending this stub, so the access package
no longer compiles under test on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(jellycompat): implement onboarding methods on progress stub stores

Same upstream gap as the access resolver stub: #501 extended
userstore.UserStore without updating these test doubles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ebooks): promote ebooks resolved from complete local metadata

enrichClaimedItem returned EnrichmentOutcomeSuccess for items whose
embedded metadata already satisfied every field enrichment would fetch,
without ever running the status promotion. That was the only terminal
outcome to skip it: no_match, author-mismatch, and provider success all
reach stampLastRefreshed, the sole writer of status 'pending' -> 'matched'.

Those items therefore kept media_items.status = 'pending' permanently and
were counted by the library "unmatched" tile. On prod this is 43,653 of
the 44,014 reported unmatched items (99.2%) — every one of them with
outcome='success', last_refreshed IS NULL, matched_at IS NULL, and title,
author, overview and cover all populated. Rescanning cannot clear them,
because the short-circuit fires again and the queue re-parks the row.

last_refreshed is deliberately left untouched: it gates the admin
quick-refresh sweep, and no provider was consulted here, so these items
must stay eligible for a later refresh that could attach a provider
identity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(docker): keep agent scratch dirs out of the build context

.claude carries gitignored worktrees and caches that reached 2.8GB locally,
and .superpowers is similar. Neither is needed to build the image, but
.dockerignore did not exclude them, so both were shipped to the daemon as
build context (and rsync'd to deploy hosts).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore(rebase): preserve current-main formatting

* fix(metadata): harden book enrichment backlog

* chore(metadata): satisfy changed-lines lint

* test(audiobooks): cover provider error precedence

* feat(auth): scoped API keys and admin user API hygiene

Scoped API keys: an sa_ key may now carry an allowlist of scopes
(admin:users, admin:access-groups:read). A scoped key is refused on every
route its scopes do not name — including the Jellyfin-compat surface and
plugin access — so an integration credential (e.g. a billing system doing
user provisioning) no longer needs a full-power admin key. Empty scopes
keep the existing behavior. Scopes narrow, never grant: role checks still
apply to the owning user.

Admin user API hygiene:
- POST /admin/users maps a duplicate username/email to 409 duplicate
  instead of an opaque 500, so clients can distinguish a lost-response
  retry from a genuine server error.
- DELETE /admin/users/{id} maps a missing user to 404, making terminate
  retries idempotent from the client's point of view.
- POST /admin/users accepts access_group_id (the repository already
  supported it), removing the create-then-update window where a new user
  briefly sat under the default access group.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(access): inherit/override user policy resolution

User policy fields move from "strictest of user and group wins" to
inherit/override: NULL on the user row means "inherit the access group's
value"; a set value is an explicit per-user override that replaces the
group value in either direction — grant or restrict. A group saying "no
downloads" no longer forbids granting downloads to one of its members,
and a member's cap above the group's now wins instead of being clamped.

- users: max_streams, max_transcodes, max_playback_quality,
  transcode_allowed, audio_transcode_allowed, download_allowed,
  download_transcode_allowed, library_ids become nullable (NULL =
  inherit); new nullable requests_allowed. Numeric 0 becomes an explicit
  "unlimited" override instead of a delegation sentinel.
- access_groups: gain transcode_allowed / audio_transcode_allowed so
  every user field has a group value to inherit.
- resolution collapses to effective = user.field ?? group.field ??
  permissive no-group default; permissions keep the allowed_permissions
  intersection mask. All enforcement reads go through the resolver,
  including the previously raw fallbacks (items/sections/libraries
  library scope, legacy permission middleware, /auth/me and login
  download_allowed, requests gate — which now honors a user override).
- downloads package no longer launders effective policy back into
  models.User; checks take a resolved PolicyUser.
- admin user API: PUT accepts explicit null per policy field to clear an
  override back to inherit (tri-state), GET reports stored overrides
  (null = inherited) plus a resolved effective_policy block; access-group
  API carries the two new gates.
- migration maps old delegate values (0 / '' / true) to NULL and keeps
  restrictive values as overrides, so existing behavior is preserved
  except the deliberate cap-above-group change.
- web admin: user forms get per-field Inherit/Override controls showing
  the inherited effective value, saves send explicit null for inherited
  fields (no more silent pinning on save), the user overview shows
  effective values with override provenance, and the access-group editor
  gains the video/audio transcoding toggles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(access): review fixes for inherit/override reporting

Findings from the adversarial review of c025a6e6:

- An explicit empty library_ids override ([] = no libraries) round-trips
  as [] in admin responses instead of collapsing to null (inherit), so an
  admin open+save no longer silently deletes a deny-all override.
- effective_policy now fails closed: a failed access-group lookup returns
  500 instead of silently rendering a group-restricted user against the
  permissive no-group default.
- Migration maps a pre-existing NULL download gate (the columns were
  always nullable) to inherit instead of inventing an explicit deny
  override, and the down block documents its inherent lossiness for
  explicit permissive overrides.
- web: updating an access group invalidates user queries so effective
  values and inherit hints refresh; changing the group inside the user
  edit dialog degrades inherit hints to generic labels instead of showing
  the old group's values; the invitation form labels null library scope
  as inherit-from-group rather than 'All libraries'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): refine featured hero metadata

* fix(access): close scoped-key escalation and policy review findings

Scoped API keys: an admin:users key could create or take over an admin
account and log in for an unscoped JWT. Scoped actors may no longer
assign the admin role or change credentials on an existing admin.
Self-service POST /api/v1/api-keys now honours `scopes` instead of
silently dropping them, and GET /api/v1/api-keys/scopes advertises
the catalog for feature detection.

Policy resolution: download_transcode_allowed was the one column whose
old default was false, so the migration froze every account as an
explicit deny; it now maps false to inherit, NoGroupPolicy matches the
old default, and Down restores the correct default. Legacy negative
caps map to inherit instead of unlimited. Ungrouped users no longer
query the group store, home sections and item filters fail closed on
a policy error like /libraries, the legacy metadata gate resolves
inherited libraries, and the requests service no longer has a dead
group-only fallback.

Web admin: inherit hints follow the selected group on both tabs and on
the create form, Override no longer seeds 0 (= unlimited), an empty
limit box cannot be saved as 0, the effective panel shows the
group-intersected permissions and the audio-transcode row, and the
access-group copy describes inherit/override.

Cleanups: one tri-state decoder, generic clonePtr, shared cap
validator, table-driven user Update, single policy field table in the
web form, orphaned UserTranscodeLimitField removed, unused
OverrideSources removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: streamline project and contribution guidance

* docs: link support section from README header

* docs: address review feedback

* docs: clarify adversarial review disclosure

* docs: fix review findings and tighten prose across PR #683

Corrections:
- downloads-api §4.11: proxy_delivery means the routes are mounted, not that
  requests redirect; artifact relay applies to /downloads/{id}/file only.
- docker.md: give a real pg_dump backup (live data-dir copies are torn),
  restart postgres+silo together, explain long migrations vs the healthcheck,
  mention --migrate-status/--migrate-down-to, state compat ports are on by
  default.
- AGENTS.md: align "Related issue" wording with the PR template; point the
  pre-PR gate at CONTRIBUTING instead of carrying a second list.
- .env.example: leave POSTGRES_PASSWORD commented so the quick-start append
  is the only definition.
- Issue forms: drop the required Adversarial review field; restore the
  fabricated-report block warning in the bug-report intro.
- feature-changelog: add the missing entry for proxy delivery (#607).

Consolidation:
- One pre-submission gate (CONTRIBUTING), one quick start (docker.md), one
  go.work paragraph (DEVELOPMENT), one AI disclosure block wording.
- README drops the duplicated quick start, tag table, doc index, and filler.
- PR template checklist reduced to the two items its sections don't cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): move the person photo triple as a unit during enrichment

Address review findings on the credit-enrichment artwork guard.

photo_path, photo_source_path, and photo_thumbhash describe one image, but
each column was gated on its own value. A credit carries a photo URL and never
a source path, so replacing the "-" no-photo sentinel rewrote photo_path while
leaving the previous source URL in place. photo_source_path is what
UpdatePhotoIfSourceMatches keys the image-cache handshake on and what
EnqueueExistingProviderArtwork downloads from, so the finished job landed the
*old* image on the row, under the old image's thumbhash. All three columns now
move together under one decision taken on photo_path.

Deferring every replacement to the full person refresh also stranded people
with no tmdb/imdb/tvdb id: FindRefreshCandidates skips them, so nothing would
ever revisit a photo URL that had gone dead. The guard now protects cached
artwork specifically rather than any populated value — an empty column, the "-"
sentinel, and an uncached provider URL stay replaceable. "Not a cached key" is
the same LIKE '%://%' test the artwork GC trigger and the image cache sweep
use, so displacing a URL still queues nothing for deletion. Replacement
requires a genuinely different path, so re-scanning an unchanged credit remains
a no-op.

Tests: the SQL-shape test now matches whole generated clauses instead of loose
fragments, so a mis-wired column fails it, and the Postgres-backed test no
longer calls t.Fatalf on the parent T from inside a subtest. New cases cover
the stale-source binding, uncached-URL replacement, and the unchanged-credit
no-op. The behavioral coverage still needs SILO_TEST_DATABASE_URL, which CI
does not set.

Also build the batch enrichment SQL once instead of per batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(contrib): require a readability prose pass and vendor the unslop skill

AI-written PR and issue bodies routinely arrive padded with filler and
promotional framing that costs review time. Vendor the unslop skill into
.claude/skills/ so contributors' agents pick it up in-repo, and add a
Prose pass section to docs/ai-contributions.md making the pass an
expectation. Worded explicitly as readability, not concealment: it may
not alter facts, pasted output, or logs, and disclosure still applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(access): ungroup accounts promoted to admin

Create already leaves admins ungrouped because playback and catalog
policy is role-blind. Update did not: promoting a Default Group member
kept that group's stream cap and library list. Drop the group on
promote, reject assigning one to an existing admin, and ignore an
explicit group on admin create.

Co-authored-by: Quick <Quick104@users.noreply.github.com>

* fix(access): make admin accounts ungrouped everywhere

Review follow-up for the promote-clears-group fix. The rule now has one
write-side owner and one read-side guard instead of five copies:

- UserRepository.Update clears the group on promote and lands a demoted
  admin on the default group unless the write names one, so an ex-admin
  never becomes an uncapped non-admin.
- access.EffectivePolicyForUser ignores any group an admin row still
  carries (GroupApplies), covering every write path and pre-existing data.
- A data migration clears admins grouped before this rule and bumps their
  policy revision.
- PUT /admin/users/{id} rejects role=admin + access_group_id with 422
  whether the role is echoed or not, matching POST /admin/users; the
  handler no longer pre-clears the group itself.
- Invitations reject admin + access_group_id at send (422) instead of
  storing a group that accept silently drops.
- Web forms derive access_group_id=null for admins at submit; the detail
  form no longer wipes the picked group on a role toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(access): enforce admin ungrouping atomically and in list responses

Review follow-up:

- A group written without a role change is resolved against the row's
  current role inside the UPDATE, so a write racing a promotion cannot
  leave an admin grouped; the migration also adds a
  users_admin_ungrouped CHECK constraint as the durable backstop.
- GET /admin/users applies the same GroupApplies guard as the detail and
  auth endpoints, so a legacy grouped admin row never reports group
  ceilings anywhere.
- Regression test for toggling the role to admin and back keeping the
  picked group.
- Lint: spelling, wasted assignment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(access): stage the admin-ungrouped constraint and clear pending admin invitations

Review follow-up:

- Drop the CHECK constraint from this release's migration: in a rolling
  upgrade, previous-version nodes still promote without clearing the
  group and the constraint would surface as a 500. The repository's
  in-statement CASE and the read-side guard already hold the invariant;
  add the constraint once every writer is on this version.
- The migration also clears the group on still-pending admin invitations
  created before this rule, so they advertise what accept will do.
- Both user forms preview the no-group policy while Admin is selected
  instead of the retained group's ceilings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): smooth detail action interactions

Co-authored-by: OpenAI Codex (GPT-5) <codex@openai.com>

* test(web): cover detail interaction variants

Co-authored-by: OpenAI Codex (GPT-5) <codex@openai.com>

* feat(playback): add header-authenticated media transport

* feat(playback): negotiate bounded software decode

* fix(web): scale poster overlays with card width

* fix(web): scale poster overlay shadows

* fix(web): prevent detail action hover repaints

* fix(web): keep detail action hover compositor-only

* fix(web): preserve disabled action affordance

* fix(abs): key the login rate limiter on the transport peer

clientip.Middleware overwrites r.RemoteAddr with the header-derived viewer
address whenever the TCP peer is a trusted proxy, which includes Docker's
bridge. Mounting it on the ABS listener therefore defeated the login limiter's
deliberate RemoteAddr-only keying: an attacker behind any reverse proxy could
rotate X-Forwarded-For and buy a fresh burst bucket per request.

The middleware now preserves the pre-overwrite peer address in the request
context, and the limiter reads that instead. Anything else that must key on an
address a client cannot forge should do the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(jellycompat): key stream telemetry on the upstream playback session

Compat attached observations under PlaybackSession.ID while the proxy,
nodesessions and playback_sessions_sync all key on playback.Session.ID, and
BuildGlobalView merges by exact SessionID string. One Jellyfin viewing therefore
showed as two merged sessions — a byte-less compat twin and the proxy record
carrying the traffic — and every compat session looked telemetry_only in parity.

Compat now attaches only under UpstreamSessionID. A play session does not learn
that id until ensureUpstreamPlayback/ensureTranscodeManifest has run, so the
pre-side-effect attach is a no-op on a session's first request and the handler
attaches again the moment the id exists, still before any byte is written. A
provisional key was rejected deliberately: it recreates exactly the ghost session
this fixes, and a session whose id did not exist a moment ago cannot have a
pending cut against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(streamtelemetry): make Truncated recoverable and hold early realtime state

Three defects in the process-local registry, all found by review:

Truncated was sticky for the process lifetime. drop() set it and nothing ever
cleared it, so one transient capacity burst pinned the merged view's Complete
to false until a restart and made a later real truncation indistinguishable. It
now decays over Freshness — the same horizon BuildGlobalView uses to decide a
publisher is current — while the monotonic Dropped* counters keep the permanent
record.

SetRealtimeConnection was a no-op when the session did not exist yet. That is
the normal client ordering: the control socket opens as soon as a sessionId
exists, before the first media route is hit, so RealtimeConnectionAlive stayed
false for the whole of every live session. State for an unknown session is now
held per shard, applied when an attach creates the session, capacity-bounded
against the session budget, and pruned by the sweep.

The distributed cross-checks compared an env-supplied value against the DEFAULT
of the other knob, so setting one variable disabled distributed mode and blamed
a variable the operator never set. Knobs left at their defaults now move to
satisfy the invariant; only a pair pinned to genuinely inconsistent values is an
error, and only the variables actually set are named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(httpstream): give every ReadFrom slice a full stall window

The bumpStep throttle was written for the 32 KB Write path, where one
SetWriteDeadline per chunk would be wasteful. Applying it to ReadFrom slices
buys nothing — a slice is already bounded at 4 MiB — and costs correctness: a
slice completing less than a step after the last bump got no refresh, so the
next one started with as little as window-step remaining. The real guaranteed
floor was ~203 kbit/s, not the 186 kbit/s the constant and both design documents
promise, and a client sustaining the documented rate was reaped as stalled.

Slices now bump unconditionally, before the first as well as between each, which
is what the pre-CopyChunked loop did. Costs at most one syscall per 4 MiB.

The existing deadline tests construct the writer with step=0 and so never
exercised the throttle; the two added here fail on the unfixed code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(proxy): credit the egress meter often enough to measure slow viewers

meteredResponseWriter previously hid io.ReaderFrom on purpose, so every byte
reached egressMeter.Add through a ~32 KB Write. Forwarding ReadFrom restored
sendfile but moved crediting to once per completed 4 MiB slice, which a
200-500 kbit/s direct-play viewer takes 60-170 s to fill. RateKbps averages over
60 s, so those streams read as zero for most samples: /api/v1/status
under-reports committed egress and nodepool's effectiveEgressKbps can admit
sessions onto a saturated proxy.

Metered slices are now 256 KiB — a credit every 4-10 s at those rates, well
inside the window, and still 8x more per sendfile call than the Write path it
replaced. Slice size here is a rate-fidelity constraint, not a tuning knob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(downloads): roll the direct-download deadline and carry the profile

handleDirectDownload passed the raw ResponseWriter to ServeDirect, so unlike the
sibling /downloads/{id}/file it had no rolling deadline and the API server's
absolute 120 s WriteTimeout truncated any original large enough to take longer.
Excluding the route from compression made it one unbounded sendfile, so the
whole body now rides on that single deadline.

redirectDirectDownload hardcoded an empty profile id in both the proxy redirect
and the telemetry attach, while the local branch two lines away reads the real
one. Proxy-served traffic was therefore missing from per-profile attribution in
telemetry, in the stream token claim and in the node session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(streamtelemetry): fold ranged transfers, guard delta publishes, split conflicts

Transfers were one record per HTTP request keyed by observation id, so ranged
byte routes — audiobook file reads, download resumes, ebook fetches — could
exhaust MaxTransfers within one retention window while RequestCount, the field
that exists to count exactly this, stayed pinned at 1. A transfer is now one
subject pouring one file over one route, and overlapping requests fold into it.

A delta publish rewrites only changed fields and assumed the Redis hash still
held the rest. An eviction, an out-of-band DEL, a replica failover or a lapsed
PExpire drops it with no error, leaving under-reported sessions for up to
FullResyncEvery publishes. An HLEN inside the same transaction now catches the
mismatch and forces the next publish full, self-healing in one sweep.

recordConflicts appended started_at_replaced without setting
hasIdentityConflict, so the exported flag could disagree with the exported list.
A pure authority upgrade that confirms the recorded instant now records nothing
at all — it is not a conflict and should never have consumed the budget — and a
replacement that moves the value sets both.

Also documents two limitations rather than half-fixing them: clock skew is only
detectable for a publisher running ahead, since the roster score is the
publisher's own clock; and observedWriter.ReadFrom samples the cut flag once,
which the enforcement change that first calls cut.Store has to make uniform
across h1 and h2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(httpstream): one ForwardReadFrom helper for all nine wrappers

Nine ResponseWriter wrappers across five packages hand-rolled the same tail:
assert the inner writer's io.ReaderFrom, CopyChunked through it, fall back to
io.Copy over WriterOnly. Because io.Copy finds ReaderFrom by direct assertion
and never through Unwrap, this forwarding is mandatory on every media-route
wrapper — so a fix to it had to be re-applied nine times and a missed site
silently dropped to the fallback, losing zero-copy sendfile along with that
wrapper's byte accounting.

Behavior is unchanged; each call site keeps its own chunk size and record
callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(streamtelemetry): share viewer-IP, env and client-info helpers

Four families built the same clientip-then-RemoteAddr fallback chain inline
while streamtelemetry already had it unexported; a fix to it (IPv6 handling,
say) would have had to land in four places or the families would report
different viewer addresses into the same merged view. Exported as ViewerIP and
adopted everywhere.

envEnabled was the ninth independent "is this env var truthy" parser in the
tree, each accepting slightly different spellings. Adds internal/envutil and
adopts it in both telemetry packages; the remaining copies should migrate as
the code around them is touched.

checkVersion re-parsed every record into a throwaway header struct before
unmarshalling it again into a wire type that already carries the version, so a
merged-view rebuild — measured at ~347 ms for 50 000 sessions, nearly all
decode — did the JSON work twice.

ConfigFromEnv ran twice at startup because the view cache re-read the
environment just to get ViewTTL, logging any invalid variable twice; it now
takes the TTL off the registry that already parsed it.

playbackClientInfoFromRequest wrapped PlaybackClientInfoFromRequest wrapped
playback.ClientInfoFromRequest — three names, one body. Callers now use the
playback package directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: document the admin stream-telemetry parity endpoint

CLAUDE.md requires a docs/*-api.md entry and a changelog entry for a
client-visible API change. No admin-API document existed — the ~20 sibling
routes in the same router block are undocumented too — so this adds one, scoped
honestly to what it covers, with the full response shape for
GET /api/v1/admin/stream-telemetry/parity and the caveats an operator needs to
read a report correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: distill the streaming write-deadline design into architecture

PR #675 pruned docs/superpowers and the shipped design artifacts, distilling the
durable content into docs/architecture first. The streaming write-deadline
document was deleted on main under that rollup while this branch was extending
its writer-chain conformance section, which is the whole of the conflict between
the two.

This carries the durable half forward on main's own pattern: the invariants a
future change has to respect — the rolling-deadline contract, why slice size is a
correctness constraint rather than a knob, the two rules every ResponseWriter
wrapper on a media route must follow, the one-limiter sendfile trap, why chi's
compressor is bypassed rather than repaired, and how conformance is actually
verified. The one-shot half — the 2026-07-09 debugging session, the per-file
application table, the rollout plan, the silo-apple follow-up list — goes with
the deletion.

Also records the two rules this branch's review turned up: the bump throttle
belongs to Write and never to a ReadFrom slice, and the proxy egress meter has
the same shape of constraint at a different value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: ignore skill secrets and state paths

- Ignore `.secrets` and `.state` paths regardless of whether they are files or directories

* feat(playback): tokenless V3 playback, DV7 client transforms, admin transcode honesty

Playback protocol V3:
- Tokenless playback: header-authenticated media with signed stream URL
  reconstruction, sticky per-attempt feature set, and tokenless subtitle
  delivery (playback_v3, resolver, transcode manager, protocol_v3).
- Downloads and auth updates supporting the same flow; access-group clause
  coverage for repository queries.

Admin activity honesty:
- Plumb target_audio_channels end to end (new migration, session sync,
  reconciler, admin session payload, web types) so a transcode target
  renders its real output layout ("AAC 5.1"), falling back to the bare
  codec when unknown - never the source channel count.
- Rename the "Audio SW" chip to "Audio Transcode"; it labels a plan
  decision (video copied, audio transcoded), not a client capability.

Client counterpart: silo-apple branch t3code/replace-custom-engine-aether
(AetherEngine player). This server branch is required for that client -
AetherEngine playback negotiation (tokenless media, DV Profile 7
client-transform grants) does not work against older servers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(metadata): bulk persist seasons and episodes

* test(metadata): fix localized fixture spelling

* perf(web): reduce detail interaction latency

* test(metadata): adapt query-count coverage to upstream API

* fix(playback): regenerate conformance matrix for software_video_decode_v1

make verify-playback-fixtures failed on CI because one matrix entry was
missing the new server feature string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address latency review feedback

* fix(downloads): apply the coarse resolution ceiling when the detailed bounds walk cannot run

With detailed video_decode evidence and sparse probe metadata, Resolve
skipped both the per-decoder bounds walk and the flat max_resolution
ceiling, approving original-quality downloads beyond the device ceiling.
Sparse metadata now fails closed to the flat contract, ceiling included;
complete metadata keeps letting a validated detailed entry override the
coarse ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): restore proxy and transcode-node egress for header-authenticated media

header_authenticated_media_v1 kept every media byte on the API server
because proxies could only authenticate from the signed URL token that
mode removes. A new attempt-sticky opt-in, authorized_media_origins_v1,
restores distributed egress without putting a credential back in any URL:

- Plans for an attempt that negotiated both features may return absolute,
  credential-free proxy URLs (/stream/v3/{session_id} family) for direct
  play, progressive remux, and node-executed HLS.
- The proxy is told what to serve out of band: the API writes the session
  recipe to a Redis proxy-grant store (silo:proxygrant:, sibling of the
  noderecipe handoff), overwritten on replan and revoked on session stop,
  abort, and uncommitted-transport rollback.
- The proxy authenticates the caller itself: bearer JWT against the live
  signing secret plus the same auth_sessions liveness check the API runs,
  then ownership against the grant. Revoking a login stops proxy playback
  immediately. Node-relay tokens are minted proxy-side and never reach
  the client.
- RecipeCard now carries DVProfile/AudioOnly so a grant-served remux
  reproduces the exact bytes the token path would have.
- The progressive-remux escalation to HLS now applies only when no proxy
  origin is available; grant-write failure falls back to the API origin
  under the same local_transcode_fallback gate as the no-origins mode.

Header-auth-only clients and deployments without a proxy pool keep the
current API-local behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): address automated review findings on tokenless proxy egress

- Preserve the displaced proxy grant across a replan and restore it on
  rollback, so a failed replacement no longer 404s the restored plan's
  proxy URL; revoke the grant when a proxy-egress attempt commits onto a
  transport the API serves itself (identity, relay, or local transcode).
- Gate the progressive-remux escalation on a usable grant store as well as
  configured proxies: a process that can never authorize proxy egress
  escalates to HLS instead of refusing forever, while transient proxy
  ineligibility keeps the legacy retryable refusal.
- Advertise target_audio_channels in the admin sessions capability
  endpoint so independently deployed clients can feature-detect it.
- Reject an unrecognized video_evidence value on flat download payloads
  instead of silently resolving from flat claims.
- Handle SessionUnauthorized defensively in the stream and jellycompat
  serve switches (unreachable today; prevents a nil dereference if the
  caller invariants ever drift).
- Document the tokenless replica-affinity constraint in the protocol spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): survive transcode-node restarts on tokenless attempts and stop charging unused proxies

- A header-authenticated remote transcode published no stream token, so
  after a transcode-node restart neither the client nor the API relay had
  a recipe to forward and playback 404ed until a replan. The API now
  writes the transport's recipe card to the shared noderecipe store
  (keyed by transport id, like the jellycompat handoff), and the node's
  reconstruct path falls back to the store when no X-Silo-Stream-Token is
  present — the token was a recipe source, never the route's
  authorization. Recipes are deleted on every deliberate teardown
  (transport replacement, rollback, session stop/abort); the TTL only
  backstops a crashed API process.
- When a start reserved a proxy+transcode pair but published a URL the
  proxy does not serve (unwritable egress grant, or the legacy no-token
  fallback), the planner kept charging the proxy's job slot and estimated
  bandwidth until the reservation aged out. New ReleaseSessionProxy drops
  only the proxy half; the transcode node keeps its charge because it is
  running the job.
- The proxy-grant store interface is renamed recipeCardStoreV3 and shared
  by both handler fields, since it now carries two key spaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(transcodenode): check CloseProcess error in tokenless reconstruct test

golangci-lint errcheck failed CI on the new changed line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(streamtelemetry): enrol tokenless /stream/v3 proxy routes

The merge left the five credential-free grant routes registered but
unclassified, so bytes served through authorized_media_origins_v1 were
invisible to stream telemetry. Enrol them:

- Declare GET+HEAD /stream/v3/{session_id} (playback), GET+HEAD
  .../master.m3u8 (manifest) and GET .../segment/{name} (playback), all
  viewer egress and capability-relevant, and wrap each registration in
  observeProxy.
- Give them CanonicalSessionKey "verified_media_grant" rather than the
  "verified_stream_token" the proxyRoute helper hardcodes. The field is
  descriptive — it is only compared in sameDeclaration and emitted into the
  route manifest, and no code branches on its value — but these routes prove
  entitlement with a Redis grant plus the caller's own bearer token, never a
  stream token, so labelling them otherwise would be false.
- Attach the viewer in relayGrantToTranscodeNode, the single path both grant
  transcode handlers take. The proxy->node hop itself stays internal_relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(streamtelemetry): enable by default and derive distributed mode from redis

Stream telemetry measured nothing on a fresh install: both switches were
opt-in, so the parity comparison every P1 threshold depends on only ever ran
where someone had already read the design doc. Observation is process-local,
off the hot path and bounded, so the safer default is on.

SILO_STREAM_TELEMETRY_ENABLED now defaults to true and is a per-process kill
switch; SILO_STREAM_TELEMETRY_FAMILIES still narrows observation or drops one
misbehaving family without losing the rest. SILO_STREAM_TELEMETRY_DISTRIBUTED
is no longer a flag the operator has to keep in sync with their topology:
unset, the mode follows whether Redis is configured, so a single-container
install stays on LocalStore and a cluster merges. Setting it pins the mode
either way, and a rejected distributed configuration pins it off so the
derivation cannot re-enable exactly what was just refused.

Both switches read a set-but-unparseable value as false rather than as the
default (envutil.BoolDefault). For a default-on flag that means a typo in the
kill switch turns telemetry OFF, which is the fail-safe direction: the
operator was reaching for "stop observing", and a mistyped disable that
quietly left the feature running is the failure that costs them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(streamtelemetry): observe every route family by default

The staged per-family rollout set (native, proxy, transcode_node) is removed
by owner decision: SILO_STREAM_TELEMETRY_FAMILIES left unset now observes all
five declared families (native, jellycompat, proxy, abs, transcode_node)
instead of a curated subset. The variable stays as a narrowing/kill knob —
naming it takes families away rather than staging them in.

Adds streamtelemetry.AllFamilies as the single canonical family list so
ObservesFamily and ObservedFamilies don't hand-duplicate it, updates the
design doc's family-gate section and env table to match present-tense
behavior (keeping the original staged-rollout narrative as history), and
updates the feature changelog to say every family is observed out of the box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scanner): persist H.264 copy-safety verdicts and move analysis off browse paths

The multi-PPS copy-safety scan ran on media-page load and was forgotten on
every restart, re-reading the opening seconds of every browsed H.264 file —
painfully slow on remote storage. The verdict is now persisted on media_files
(self-validating against file size+mtime, so in-place rewrites invalidate it
without writer coordination), the scan window drops from 15s to 5s, browse
pages never trigger the scan (EnsureProbeOnly), and concurrent first scans
share one ffmpeg via singleflight. The lazy path stays fail-closed and
stateless on errors.

Related issue: N/A — narrow fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): optimistic remux race with server-initiated plan invalidation

When an H.264 file's copy-safety verdict is unknown, playback no longer
blocks on the bitstream scan: the planner issues the remux optimistically,
the scan runs behind the plan, and an unsafe verdict withdraws it. Sessions
that negotiated the new plan_invalidated_v1 feature get a pushed
plan_invalidated realtime command and switch via their normal
failure_recovery replan; everything else — including today's mobile apps —
is stopped and recovers onto a transcode through the persisted verdict.
Watch pages and playback start now never wait on the scan. jellycompat
sessions are exempt: their route selection does not consult the verdict yet.

Web client implements the feature; Apple/Android follow-ups tracked in their
repos.

Related issue: #135

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): sweep sessions that register after a copy-unsafe verdict lands

The async scan can beat the start path by milliseconds: a plan is decided,
the verdict persists before the session is registered, and the notifier's
immediate pass finds nothing — leaving the session on a condemned remux
route with no second look (observed live on dev: plan at t, verdict at
t+4ms, playback restarting on corrupt output). VideoCopyUnsafe now schedules
one file-wide sweep after the settle window that considers only sessions the
immediate pass never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): harden copy-safety invalidation against review findings

Four fixes from PR review: the web client defers a plan_invalidated that
races an in-flight replan adoption instead of no-opping it; a race scan that
finds another replica already persisted an unsafe verdict still notifies its
own sessions; stopping a session now interrupts an in-flight progressive
remux response (previously only the client could end it — ffmpeg was bound
solely to the request context); and background scans are capped at four
concurrent ffmpeg processes globally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): validate realtime command ownership before consuming it

Review fixes: a realtime result naming another session's command is now
rejected before the tracker deadline is canceled or the record dropped; the
concurrent-scan test waits on observable state (a gated fake ffmpeg) instead
of a fixed sleep; changelog wording no longer overclaims verdict permanence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): close copy-safety races in replan commits, transport stops, and reconstruction

Review round two: sessions the notifier could not classify mid-replan-commit
stay eligible for the post-settle sweep instead of being marked handled;
WatchTransportStop returns an already-closed channel for a session stopped
before registration; reconstructing a video stream-copy transport (progressive
or HLS) now consults the persisted verdict, closing the replica-failover hole
where a condemned remux could be re-served with nothing left to withdraw it;
and a verdict whose database write failed is memoized as unpersisted and the
write retried on later requests without rerunning ffmpeg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): gate copy-unsafe revivals before reconstruction and per file generation

Round three review fixes: the reconstruction verdict gate moves ahead of
session registration in loadTranscodeServeSession, so refused revivals cover
the remote-node proxy branch and can no longer poison stream admission with a
leaked session; a failed local scan re-reads the row and applies a verdict
another replica persisted concurrently; and the scan singleflight is keyed by
file generation (id+size+mtime) so a replaced file cannot consume the old
generation's verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): re-engage the copy-safety race on revival and close generation races

Round four review fixes, closed as one gap: a video stream-copy transport
revived or replanned while the verdict was unknown or unpersisted never
re-engaged the race machinery. KnownCopySafetyVerdict answers from memo then
row (retrying an unpersisted write, never running ffmpeg); both revival paths
consult it and kick the racer when nothing condemns the card; and a race
request arriving mid-scan queues one follow-up pass instead of being dropped.
Verdict writes are now conditional on the scanned file generation so a slow
old-generation scan can neither overwrite the replacement's verdict nor
notify its sessions. The web client scopes its adoption-settle wait to the
load sequence that owns the session, so a hung superseded start cannot stall
an invalidation past the command deadline. Test hygiene: atomic node-hit
counter, observable wait instead of a sleep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): let original players manage HDR

Accept delivery-scoped client claims for Aether-managed dynamic range and selected audio on original HTTP while retaining packaged-output gates and the existing behavior for clients that do not claim support.

* fix(catalog): reject disabled dual-library items

* chore(catalog): satisfy changed-lines lint

* fix(sections): enforce disabled episode hydration scope

* fix(scanner): scope file reconciliation to changed path

* fix(scanner): unify present-state repair and drop dead extras sync call

Review follow-ups for the file-scoped reconciliation:

- Collapse syncPresentLibraryState and syncPresentFileState into one
  syncPresentState implementation with an optional exact-path scope, so the
  two variants cannot drift. The folder-wide entry point emits the same SQL
  as before; the episode-membership statement is unified on the sibling-join
  shape so first_seen_at always aggregates over all of an episode's active
  files.
- Run the four repair statements in a single transaction instead of four
  autocommit round-trips, so a crash mid-repair cannot leave a row with its
  links cleared but its memberships unrestored.
- Remove the syncPresentFileState call in the extras-conversion branch: the
  preceding Upsert already nulls the row's content/episode links, making
  every statement a no-op there. The membership cleanup at that site comes
  from the unchanged reconcileLibraryMemberships call.
- Drop the redundant COALESCE inside GREATEST in the series denorm bump.
- Extend the regression test to drive both scopes through the unified body,
  including dangling content link repair.

Co…
JonahMMay added a commit to Prairie-Server/prairie-server that referenced this pull request Aug 28, 2026
* fix(catalog): move the person photo triple as a unit during enrichment

Address review findings on the credit-enrichment artwork guard.

photo_path, photo_source_path, and photo_thumbhash describe one image, but
each column was gated on its own value. A credit carries a photo URL and never
a source path, so replacing the "-" no-photo sentinel rewrote photo_path while
leaving the previous source URL in place. photo_source_path is what
UpdatePhotoIfSourceMatches keys the image-cache handshake on and what
EnqueueExistingProviderArtwork downloads from, so the finished job landed the
*old* image on the row, under the old image's thumbhash. All three columns now
move together under one decision taken on photo_path.

Deferring every replacement to the full person refresh also stranded people
with no tmdb/imdb/tvdb id: FindRefreshCandidates skips them, so nothing would
ever revisit a photo URL that had gone dead. The guard now protects cached
artwork specifically rather than any populated value — an empty column, the "-"
sentinel, and an uncached provider URL stay replaceable. "Not a cached key" is
the same LIKE '%://%' test the artwork GC trigger and the image cache sweep
use, so displacing a URL still queues nothing for deletion. Replacement
requires a genuinely different path, so re-scanning an unchanged credit remains
a no-op.

Tests: the SQL-shape test now matches whole generated clauses instead of loose
fragments, so a mis-wired column fails it, and the Postgres-backed test no
longer calls t.Fatalf on the parent T from inside a subtest. New cases cover
the stale-source binding, uncached-URL replacement, and the unchanged-credit
no-op. The behavioral coverage still needs SILO_TEST_DATABASE_URL, which CI
does not set.

Also build the batch enrichment SQL once instead of per batch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(contrib): require a readability prose pass and vendor the unslop skill

AI-written PR and issue bodies routinely arrive padded with filler and
promotional framing that costs review time. Vendor the unslop skill into
.claude/skills/ so contributors' agents pick it up in-repo, and add a
Prose pass section to docs/ai-contributions.md making the pass an
expectation. Worded explicitly as readability, not concealment: it may
not alter facts, pasted output, or logs, and disclosure still applies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(access): ungroup accounts promoted to admin

Create already leaves admins ungrouped because playback and catalog
policy is role-blind. Update did not: promoting a Default Group member
kept that group's stream cap and library list. Drop the group on
promote, reject assigning one to an existing admin, and ignore an
explicit group on admin create.

Co-authored-by: Quick <Quick104@users.noreply.github.com>

* fix(access): make admin accounts ungrouped everywhere

Review follow-up for the promote-clears-group fix. The rule now has one
write-side owner and one read-side guard instead of five copies:

- UserRepository.Update clears the group on promote and lands a demoted
  admin on the default group unless the write names one, so an ex-admin
  never becomes an uncapped non-admin.
- access.EffectivePolicyForUser ignores any group an admin row still
  carries (GroupApplies), covering every write path and pre-existing data.
- A data migration clears admins grouped before this rule and bumps their
  policy revision.
- PUT /admin/users/{id} rejects role=admin + access_group_id with 422
  whether the role is echoed or not, matching POST /admin/users; the
  handler no longer pre-clears the group itself.
- Invitations reject admin + access_group_id at send (422) instead of
  storing a group that accept silently drops.
- Web forms derive access_group_id=null for admins at submit; the detail
  form no longer wipes the picked group on a role toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(access): enforce admin ungrouping atomically and in list responses

Review follow-up:

- A group written without a role change is resolved against the row's
  current role inside the UPDATE, so a write racing a promotion cannot
  leave an admin grouped; the migration also adds a
  users_admin_ungrouped CHECK constraint as the durable backstop.
- GET /admin/users applies the same GroupApplies guard as the detail and
  auth endpoints, so a legacy grouped admin row never reports group
  ceilings anywhere.
- Regression test for toggling the role to admin and back keeping the
  picked group.
- Lint: spelling, wasted assignment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(access): stage the admin-ungrouped constraint and clear pending admin invitations

Review follow-up:

- Drop the CHECK constraint from this release's migration: in a rolling
  upgrade, previous-version nodes still promote without clearing the
  group and the constraint would surface as a 500. The repository's
  in-statement CASE and the read-side guard already hold the invariant;
  add the constraint once every writer is on this version.
- The migration also clears the group on still-pending admin invitations
  created before this rule, so they advertise what accept will do.
- Both user forms preview the no-group policy while Admin is selected
  instead of the retained group's ceilings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): smooth detail action interactions

Co-authored-by: OpenAI Codex (GPT-5) <codex@openai.com>

* test(web): cover detail interaction variants

Co-authored-by: OpenAI Codex (GPT-5) <codex@openai.com>

* feat(playback): add header-authenticated media transport

* feat(playback): negotiate bounded software decode

* fix(web): scale poster overlays with card width

* fix(web): scale poster overlay shadows

* fix(web): prevent detail action hover repaints

* fix(web): keep detail action hover compositor-only

* fix(web): preserve disabled action affordance

* fix(abs): key the login rate limiter on the transport peer

clientip.Middleware overwrites r.RemoteAddr with the header-derived viewer
address whenever the TCP peer is a trusted proxy, which includes Docker's
bridge. Mounting it on the ABS listener therefore defeated the login limiter's
deliberate RemoteAddr-only keying: an attacker behind any reverse proxy could
rotate X-Forwarded-For and buy a fresh burst bucket per request.

The middleware now preserves the pre-overwrite peer address in the request
context, and the limiter reads that instead. Anything else that must key on an
address a client cannot forge should do the same.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(jellycompat): key stream telemetry on the upstream playback session

Compat attached observations under PlaybackSession.ID while the proxy,
nodesessions and playback_sessions_sync all key on playback.Session.ID, and
BuildGlobalView merges by exact SessionID string. One Jellyfin viewing therefore
showed as two merged sessions — a byte-less compat twin and the proxy record
carrying the traffic — and every compat session looked telemetry_only in parity.

Compat now attaches only under UpstreamSessionID. A play session does not learn
that id until ensureUpstreamPlayback/ensureTranscodeManifest has run, so the
pre-side-effect attach is a no-op on a session's first request and the handler
attaches again the moment the id exists, still before any byte is written. A
provisional key was rejected deliberately: it recreates exactly the ghost session
this fixes, and a session whose id did not exist a moment ago cannot have a
pending cut against it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(streamtelemetry): make Truncated recoverable and hold early realtime state

Three defects in the process-local registry, all found by review:

Truncated was sticky for the process lifetime. drop() set it and nothing ever
cleared it, so one transient capacity burst pinned the merged view's Complete
to false until a restart and made a later real truncation indistinguishable. It
now decays over Freshness — the same horizon BuildGlobalView uses to decide a
publisher is current — while the monotonic Dropped* counters keep the permanent
record.

SetRealtimeConnection was a no-op when the session did not exist yet. That is
the normal client ordering: the control socket opens as soon as a sessionId
exists, before the first media route is hit, so RealtimeConnectionAlive stayed
false for the whole of every live session. State for an unknown session is now
held per shard, applied when an attach creates the session, capacity-bounded
against the session budget, and pruned by the sweep.

The distributed cross-checks compared an env-supplied value against the DEFAULT
of the other knob, so setting one variable disabled distributed mode and blamed
a variable the operator never set. Knobs left at their defaults now move to
satisfy the invariant; only a pair pinned to genuinely inconsistent values is an
error, and only the variables actually set are named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(httpstream): give every ReadFrom slice a full stall window

The bumpStep throttle was written for the 32 KB Write path, where one
SetWriteDeadline per chunk would be wasteful. Applying it to ReadFrom slices
buys nothing — a slice is already bounded at 4 MiB — and costs correctness: a
slice completing less than a step after the last bump got no refresh, so the
next one started with as little as window-step remaining. The real guaranteed
floor was ~203 kbit/s, not the 186 kbit/s the constant and both design documents
promise, and a client sustaining the documented rate was reaped as stalled.

Slices now bump unconditionally, before the first as well as between each, which
is what the pre-CopyChunked loop did. Costs at most one syscall per 4 MiB.

The existing deadline tests construct the writer with step=0 and so never
exercised the throttle; the two added here fail on the unfixed code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(proxy): credit the egress meter often enough to measure slow viewers

meteredResponseWriter previously hid io.ReaderFrom on purpose, so every byte
reached egressMeter.Add through a ~32 KB Write. Forwarding ReadFrom restored
sendfile but moved crediting to once per completed 4 MiB slice, which a
200-500 kbit/s direct-play viewer takes 60-170 s to fill. RateKbps averages over
60 s, so those streams read as zero for most samples: /api/v1/status
under-reports committed egress and nodepool's effectiveEgressKbps can admit
sessions onto a saturated proxy.

Metered slices are now 256 KiB — a credit every 4-10 s at those rates, well
inside the window, and still 8x more per sendfile call than the Write path it
replaced. Slice size here is a rate-fidelity constraint, not a tuning knob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(downloads): roll the direct-download deadline and carry the profile

handleDirectDownload passed the raw ResponseWriter to ServeDirect, so unlike the
sibling /downloads/{id}/file it had no rolling deadline and the API server's
absolute 120 s WriteTimeout truncated any original large enough to take longer.
Excluding the route from compression made it one unbounded sendfile, so the
whole body now rides on that single deadline.

redirectDirectDownload hardcoded an empty profile id in both the proxy redirect
and the telemetry attach, while the local branch two lines away reads the real
one. Proxy-served traffic was therefore missing from per-profile attribution in
telemetry, in the stream token claim and in the node session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(streamtelemetry): fold ranged transfers, guard delta publishes, split conflicts

Transfers were one record per HTTP request keyed by observation id, so ranged
byte routes — audiobook file reads, download resumes, ebook fetches — could
exhaust MaxTransfers within one retention window while RequestCount, the field
that exists to count exactly this, stayed pinned at 1. A transfer is now one
subject pouring one file over one route, and overlapping requests fold into it.

A delta publish rewrites only changed fields and assumed the Redis hash still
held the rest. An eviction, an out-of-band DEL, a replica failover or a lapsed
PExpire drops it with no error, leaving under-reported sessions for up to
FullResyncEvery publishes. An HLEN inside the same transaction now catches the
mismatch and forces the next publish full, self-healing in one sweep.

recordConflicts appended started_at_replaced without setting
hasIdentityConflict, so the exported flag could disagree with the exported list.
A pure authority upgrade that confirms the recorded instant now records nothing
at all — it is not a conflict and should never have consumed the budget — and a
replacement that moves the value sets both.

Also documents two limitations rather than half-fixing them: clock skew is only
detectable for a publisher running ahead, since the roster score is the
publisher's own clock; and observedWriter.ReadFrom samples the cut flag once,
which the enforcement change that first calls cut.Store has to make uniform
across h1 and h2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(httpstream): one ForwardReadFrom helper for all nine wrappers

Nine ResponseWriter wrappers across five packages hand-rolled the same tail:
assert the inner writer's io.ReaderFrom, CopyChunked through it, fall back to
io.Copy over WriterOnly. Because io.Copy finds ReaderFrom by direct assertion
and never through Unwrap, this forwarding is mandatory on every media-route
wrapper — so a fix to it had to be re-applied nine times and a missed site
silently dropped to the fallback, losing zero-copy sendfile along with that
wrapper's byte accounting.

Behavior is unchanged; each call site keeps its own chunk size and record
callback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(streamtelemetry): share viewer-IP, env and client-info helpers

Four families built the same clientip-then-RemoteAddr fallback chain inline
while streamtelemetry already had it unexported; a fix to it (IPv6 handling,
say) would have had to land in four places or the families would report
different viewer addresses into the same merged view. Exported as ViewerIP and
adopted everywhere.

envEnabled was the ninth independent "is this env var truthy" parser in the
tree, each accepting slightly different spellings. Adds internal/envutil and
adopts it in both telemetry packages; the remaining copies should migrate as
the code around them is touched.

checkVersion re-parsed every record into a throwaway header struct before
unmarshalling it again into a wire type that already carries the version, so a
merged-view rebuild — measured at ~347 ms for 50 000 sessions, nearly all
decode — did the JSON work twice.

ConfigFromEnv ran twice at startup because the view cache re-read the
environment just to get ViewTTL, logging any invalid variable twice; it now
takes the TTL off the registry that already parsed it.

playbackClientInfoFromRequest wrapped PlaybackClientInfoFromRequest wrapped
playback.ClientInfoFromRequest — three names, one body. Callers now use the
playback package directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: document the admin stream-telemetry parity endpoint

CLAUDE.md requires a docs/*-api.md entry and a changelog entry for a
client-visible API change. No admin-API document existed — the ~20 sibling
routes in the same router block are undocumented too — so this adds one, scoped
honestly to what it covers, with the full response shape for
GET /api/v1/admin/stream-telemetry/parity and the caveats an operator needs to
read a report correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: distill the streaming write-deadline design into architecture

PR #675 pruned docs/superpowers and the shipped design artifacts, distilling the
durable content into docs/architecture first. The streaming write-deadline
document was deleted on main under that rollup while this branch was extending
its writer-chain conformance section, which is the whole of the conflict between
the two.

This carries the durable half forward on main's own pattern: the invariants a
future change has to respect — the rolling-deadline contract, why slice size is a
correctness constraint rather than a knob, the two rules every ResponseWriter
wrapper on a media route must follow, the one-limiter sendfile trap, why chi's
compressor is bypassed rather than repaired, and how conformance is actually
verified. The one-shot half — the 2026-07-09 debugging session, the per-file
application table, the rollout plan, the silo-apple follow-up list — goes with
the deletion.

Also records the two rules this branch's review turned up: the bump throttle
belongs to Write and never to a ReadFrom slice, and the proxy egress meter has
the same shape of constraint at a different value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: ignore skill secrets and state paths

- Ignore `.secrets` and `.state` paths regardless of whether they are files or directories

* feat(playback): tokenless V3 playback, DV7 client transforms, admin transcode honesty

Playback protocol V3:
- Tokenless playback: header-authenticated media with signed stream URL
  reconstruction, sticky per-attempt feature set, and tokenless subtitle
  delivery (playback_v3, resolver, transcode manager, protocol_v3).
- Downloads and auth updates supporting the same flow; access-group clause
  coverage for repository queries.

Admin activity honesty:
- Plumb target_audio_channels end to end (new migration, session sync,
  reconciler, admin session payload, web types) so a transcode target
  renders its real output layout ("AAC 5.1"), falling back to the bare
  codec when unknown - never the source channel count.
- Rename the "Audio SW" chip to "Audio Transcode"; it labels a plan
  decision (video copied, audio transcoded), not a client capability.

Client counterpart: silo-apple branch t3code/replace-custom-engine-aether
(AetherEngine player). This server branch is required for that client -
AetherEngine playback negotiation (tokenless media, DV Profile 7
client-transform grants) does not work against older servers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(metadata): bulk persist seasons and episodes

* test(metadata): fix localized fixture spelling

* perf(web): reduce detail interaction latency

* test(metadata): adapt query-count coverage to upstream API

* fix(playback): regenerate conformance matrix for software_video_decode_v1

make verify-playback-fixtures failed on CI because one matrix entry was
missing the new server feature string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address latency review feedback

* fix(downloads): apply the coarse resolution ceiling when the detailed bounds walk cannot run

With detailed video_decode evidence and sparse probe metadata, Resolve
skipped both the per-decoder bounds walk and the flat max_resolution
ceiling, approving original-quality downloads beyond the device ceiling.
Sparse metadata now fails closed to the flat contract, ceiling included;
complete metadata keeps letting a validated detailed entry override the
coarse ceiling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): restore proxy and transcode-node egress for header-authenticated media

header_authenticated_media_v1 kept every media byte on the API server
because proxies could only authenticate from the signed URL token that
mode removes. A new attempt-sticky opt-in, authorized_media_origins_v1,
restores distributed egress without putting a credential back in any URL:

- Plans for an attempt that negotiated both features may return absolute,
  credential-free proxy URLs (/stream/v3/{session_id} family) for direct
  play, progressive remux, and node-executed HLS.
- The proxy is told what to serve out of band: the API writes the session
  recipe to a Redis proxy-grant store (silo:proxygrant:, sibling of the
  noderecipe handoff), overwritten on replan and revoked on session stop,
  abort, and uncommitted-transport rollback.
- The proxy authenticates the caller itself: bearer JWT against the live
  signing secret plus the same auth_sessions liveness check the API runs,
  then ownership against the grant. Revoking a login stops proxy playback
  immediately. Node-relay tokens are minted proxy-side and never reach
  the client.
- RecipeCard now carries DVProfile/AudioOnly so a grant-served remux
  reproduces the exact bytes the token path would have.
- The progressive-remux escalation to HLS now applies only when no proxy
  origin is available; grant-write failure falls back to the API origin
  under the same local_transcode_fallback gate as the no-origins mode.

Header-auth-only clients and deployments without a proxy pool keep the
current API-local behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): address automated review findings on tokenless proxy egress

- Preserve the displaced proxy grant across a replan and restore it on
  rollback, so a failed replacement no longer 404s the restored plan's
  proxy URL; revoke the grant when a proxy-egress attempt commits onto a
  transport the API serves itself (identity, relay, or local transcode).
- Gate the progressive-remux escalation on a usable grant store as well as
  configured proxies: a process that can never authorize proxy egress
  escalates to HLS instead of refusing forever, while transient proxy
  ineligibility keeps the legacy retryable refusal.
- Advertise target_audio_channels in the admin sessions capability
  endpoint so independently deployed clients can feature-detect it.
- Reject an unrecognized video_evidence value on flat download payloads
  instead of silently resolving from flat claims.
- Handle SessionUnauthorized defensively in the stream and jellycompat
  serve switches (unreachable today; prevents a nil dereference if the
  caller invariants ever drift).
- Document the tokenless replica-affinity constraint in the protocol spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): survive transcode-node restarts on tokenless attempts and stop charging unused proxies

- A header-authenticated remote transcode published no stream token, so
  after a transcode-node restart neither the client nor the API relay had
  a recipe to forward and playback 404ed until a replan. The API now
  writes the transport's recipe card to the shared noderecipe store
  (keyed by transport id, like the jellycompat handoff), and the node's
  reconstruct path falls back to the store when no X-Silo-Stream-Token is
  present — the token was a recipe source, never the route's
  authorization. Recipes are deleted on every deliberate teardown
  (transport replacement, rollback, session stop/abort); the TTL only
  backstops a crashed API process.
- When a start reserved a proxy+transcode pair but published a URL the
  proxy does not serve (unwritable egress grant, or the legacy no-token
  fallback), the planner kept charging the proxy's job slot and estimated
  bandwidth until the reservation aged out. New ReleaseSessionProxy drops
  only the proxy half; the transcode node keeps its charge because it is
  running the job.
- The proxy-grant store interface is renamed recipeCardStoreV3 and shared
  by both handler fields, since it now carries two key spaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(transcodenode): check CloseProcess error in tokenless reconstruct test

golangci-lint errcheck failed CI on the new changed line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(streamtelemetry): enrol tokenless /stream/v3 proxy routes

The merge left the five credential-free grant routes registered but
unclassified, so bytes served through authorized_media_origins_v1 were
invisible to stream telemetry. Enrol them:

- Declare GET+HEAD /stream/v3/{session_id} (playback), GET+HEAD
  .../master.m3u8 (manifest) and GET .../segment/{name} (playback), all
  viewer egress and capability-relevant, and wrap each registration in
  observeProxy.
- Give them CanonicalSessionKey "verified_media_grant" rather than the
  "verified_stream_token" the proxyRoute helper hardcodes. The field is
  descriptive — it is only compared in sameDeclaration and emitted into the
  route manifest, and no code branches on its value — but these routes prove
  entitlement with a Redis grant plus the caller's own bearer token, never a
  stream token, so labelling them otherwise would be false.
- Attach the viewer in relayGrantToTranscodeNode, the single path both grant
  transcode handlers take. The proxy->node hop itself stays internal_relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(streamtelemetry): enable by default and derive distributed mode from redis

Stream telemetry measured nothing on a fresh install: both switches were
opt-in, so the parity comparison every P1 threshold depends on only ever ran
where someone had already read the design doc. Observation is process-local,
off the hot path and bounded, so the safer default is on.

SILO_STREAM_TELEMETRY_ENABLED now defaults to true and is a per-process kill
switch; SILO_STREAM_TELEMETRY_FAMILIES still narrows observation or drops one
misbehaving family without losing the rest. SILO_STREAM_TELEMETRY_DISTRIBUTED
is no longer a flag the operator has to keep in sync with their topology:
unset, the mode follows whether Redis is configured, so a single-container
install stays on LocalStore and a cluster merges. Setting it pins the mode
either way, and a rejected distributed configuration pins it off so the
derivation cannot re-enable exactly what was just refused.

Both switches read a set-but-unparseable value as false rather than as the
default (envutil.BoolDefault). For a default-on flag that means a typo in the
kill switch turns telemetry OFF, which is the fail-safe direction: the
operator was reaching for "stop observing", and a mistyped disable that
quietly left the feature running is the failure that costs them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(streamtelemetry): observe every route family by default

The staged per-family rollout set (native, proxy, transcode_node) is removed
by owner decision: SILO_STREAM_TELEMETRY_FAMILIES left unset now observes all
five declared families (native, jellycompat, proxy, abs, transcode_node)
instead of a curated subset. The variable stays as a narrowing/kill knob —
naming it takes families away rather than staging them in.

Adds streamtelemetry.AllFamilies as the single canonical family list so
ObservesFamily and ObservedFamilies don't hand-duplicate it, updates the
design doc's family-gate section and env table to match present-tense
behavior (keeping the original staged-rollout narrative as history), and
updates the feature changelog to say every family is observed out of the box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(scanner): persist H.264 copy-safety verdicts and move analysis off browse paths

The multi-PPS copy-safety scan ran on media-page load and was forgotten on
every restart, re-reading the opening seconds of every browsed H.264 file —
painfully slow on remote storage. The verdict is now persisted on media_files
(self-validating against file size+mtime, so in-place rewrites invalidate it
without writer coordination), the scan window drops from 15s to 5s, browse
pages never trigger the scan (EnsureProbeOnly), and concurrent first scans
share one ffmpeg via singleflight. The lazy path stays fail-closed and
stateless on errors.

Related issue: N/A — narrow fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): optimistic remux race with server-initiated plan invalidation

When an H.264 file's copy-safety verdict is unknown, playback no longer
blocks on the bitstream scan: the planner issues the remux optimistically,
the scan runs behind the plan, and an unsafe verdict withdraws it. Sessions
that negotiated the new plan_invalidated_v1 feature get a pushed
plan_invalidated realtime command and switch via their normal
failure_recovery replan; everything else — including today's mobile apps —
is stopped and recovers onto a transcode through the persisted verdict.
Watch pages and playback start now never wait on the scan. jellycompat
sessions are exempt: their route selection does not consult the verdict yet.

Web client implements the feature; Apple/Android follow-ups tracked in their
repos.

Related issue: #135

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): sweep sessions that register after a copy-unsafe verdict lands

The async scan can beat the start path by milliseconds: a plan is decided,
the verdict persists before the session is registered, and the notifier's
immediate pass finds nothing — leaving the session on a condemned remux
route with no second look (observed live on dev: plan at t, verdict at
t+4ms, playback restarting on corrupt output). VideoCopyUnsafe now schedules
one file-wide sweep after the settle window that considers only sessions the
immediate pass never saw.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): harden copy-safety invalidation against review findings

Four fixes from PR review: the web client defers a plan_invalidated that
races an in-flight replan adoption instead of no-opping it; a race scan that
finds another replica already persisted an unsafe verdict still notifies its
own sessions; stopping a session now interrupts an in-flight progressive
remux response (previously only the client could end it — ffmpeg was bound
solely to the request context); and background scans are capped at four
concurrent ffmpeg processes globally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): validate realtime command ownership before consuming it

Review fixes: a realtime result naming another session's command is now
rejected before the tracker deadline is canceled or the record dropped; the
concurrent-scan test waits on observable state (a gated fake ffmpeg) instead
of a fixed sleep; changelog wording no longer overclaims verdict permanence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): close copy-safety races in replan commits, transport stops, and reconstruction

Review round two: sessions the notifier could not classify mid-replan-commit
stay eligible for the post-settle sweep instead of being marked handled;
WatchTransportStop returns an already-closed channel for a session stopped
before registration; reconstructing a video stream-copy transport (progressive
or HLS) now consults the persisted verdict, closing the replica-failover hole
where a condemned remux could be re-served with nothing left to withdraw it;
and a verdict whose database write failed is memoized as unpersisted and the
write retried on later requests without rerunning ffmpeg.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): gate copy-unsafe revivals before reconstruction and per file generation

Round three review fixes: the reconstruction verdict gate moves ahead of
session registration in loadTranscodeServeSession, so refused revivals cover
the remote-node proxy branch and can no longer poison stream admission with a
leaked session; a failed local scan re-reads the row and applies a verdict
another replica persisted concurrently; and the scan singleflight is keyed by
file generation (id+size+mtime) so a replaced file cannot consume the old
generation's verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): re-engage the copy-safety race on revival and close generation races

Round four review fixes, closed as one gap: a video stream-copy transport
revived or replanned while the verdict was unknown or unpersisted never
re-engaged the race machinery. KnownCopySafetyVerdict answers from memo then
row (retrying an unpersisted write, never running ffmpeg); both revival paths
consult it and kick the racer when nothing condemns the card; and a race
request arriving mid-scan queues one follow-up pass instead of being dropped.
Verdict writes are now conditional on the scanned file generation so a slow
old-generation scan can neither overwrite the replacement's verdict nor
notify its sessions. The web client scopes its adoption-settle wait to the
load sequence that owns the session, so a hung superseded start cannot stall
an invalidation past the command deadline. Test hygiene: atomic node-hit
counter, observable wait instead of a sleep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): let original players manage HDR

Accept delivery-scoped client claims for Aether-managed dynamic range and selected audio on original HTTP while retaining packaged-output gates and the existing behavior for clients that do not claim support.

* fix(catalog): reject disabled dual-library items

* chore(catalog): satisfy changed-lines lint

* fix(sections): enforce disabled episode hydration scope

* fix(scanner): scope file reconciliation to changed path

* fix(scanner): unify present-state repair and drop dead extras sync call

Review follow-ups for the file-scoped reconciliation:

- Collapse syncPresentLibraryState and syncPresentFileState into one
  syncPresentState implementation with an optional exact-path scope, so the
  two variants cannot drift. The folder-wide entry point emits the same SQL
  as before; the episode-membership statement is unified on the sibling-join
  shape so first_seen_at always aggregates over all of an episode's active
  files.
- Run the four repair statements in a single transaction instead of four
  autocommit round-trips, so a crash mid-repair cannot leave a row with its
  links cleared but its memberships unrestored.
- Remove the syncPresentFileState call in the extras-conversion branch: the
  preceding Upsert already nulls the row's content/episode links, making
  every statement a no-op there. The membership cleanup at that site comes
  from the unchanged reconcileLibraryMemberships call.
- Drop the redundant COALESCE inside GREATEST in the series denorm bump.
- Extend the regression test to drive both scopes through the unified body,
  including dangling content link repair.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: remove committed tone-map implementation plan

Plans in docs/superpowers/ are working artifacts and are never committed
(AGENTS.md); the directory is gitignored and main deleted the rest of it.

Most of what the plan described is already covered by this branch's
playback-protocol-v3.md updates — capability advertisement, the tone-map
smoke probe, Dolby Vision base-layer classification, the degradation
warning, the quality ladder and the terminal reason. Five durable rules
were not, so they are distilled into a new "Tone-map execution integrity"
section rather than lost with the plan: what a frozen recipe must carry and
what sidecar-only replan equality therefore compares; the executor-side
source re-verification before every tone-map run and its permanent-versus-
transient split; why a tone-map stream token uses the transcode_tonemap_v1
discriminator; the crash-ordered attestation receipt that makes a remote
prepared artifact fail closed at delivery; and why ambiguous Dolby Vision
provenance is refused rather than inferred.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: add feature-changelog entry for HDR tone mapping

Covers the user-facing shape of the change: HDR-to-SDR tone mapping for
SDR-only clients on streaming and prepared downloads, the two default-off
admin toggles, hardware-first execution with software fallback, Dolby
Vision Profile 7 playing via its HDR10-compatible base layer, and the
tone-map mode surfacing in admin activity.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(playback): widen gpudetect probe deadline to deflake capability test

TestResolveHWAccelWithFFmpegContextHonorsCallerDeadline gave the probe a
20ms caller deadline. That budget has to cover the fake sysfs walk in
listRenderDevices before the probe is even reached, and when the test runs
after the rest of the package that walk is cold: the deadline expires
first, exec.CommandContext declines to start the process, and the test
fails reading a probe log the fake FFmpeg never wrote. It passed only when
run alone.

Both deadlines in the test move to 60ms. That is still far below the 200ms
per-command timeout the test's own `elapsed >= 150ms` assertion exists to
distinguish the caller deadline from, so the behavior under test is
unchanged — only the headroom is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): default omitted IsRequired to true and evaluate real track data

Jellyfin's ProfileCondition defaults IsRequired to true when the JSON key
is omitted, so decode it that way instead of Go's zero value. Expose
interlacing, frame rate, video/audio bitrate, sample rate, and audio
profile from scanned track data so those conditions evaluate against real
values instead of falling through the unknown-property path, and derive
IsAnamorphic from display-vs-storage aspect ratio in both condition
evaluation and the media-stream DTO instead of hardcoding false.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: drop committed plan artifacts and add changelog entry

docs/superpowers/ is gitignored working space; the plan and spec belong
in the PR description, not the tree. Adds the feature-changelog entry for
the jellycompat condition-negotiation fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(jellycompat): let unknown condition values honor IsRequired

Legacy condition values (video level, ref frames, dimensions, bit depth,
audio channels, video profile) were always inserted even when unknown, so
numeric comparisons failed regardless of IsRequired — an unprobed level
(0 or ffprobe's -99 sentinel) failed an optional VideoLevel cap and could
eliminate every playback path for 4K media with 4K transcoding disabled.
Insert them only when known so unknowns fall through to the IsRequired
path like the newer values, and expose IsAVC to the evaluator to match
the media-stream DTO.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): clean stale copy output on recipe-changing restarts so the throttler cannot deadlock a tone-map switch

A copy generation produces segments at disk speed and races hundreds of
segments ahead of the client, and a restart only cleaned the shared output
directory when the new target was itself copy — so a copy-to-tone-map switch
left the copy manifest and its segments in place. SegmentProgress read that
stale manifest as the produced head while restart reset LastRequestedSegment,
so the throttler saw a huge bogus gap and paused the fresh ffmpeg before it
wrote its first segment; the manifest then never refreshed, the gap never
shrank, and the stream stayed paused until the user seeked. Restarts now clean
the manifest and the segments at or after the restart point whenever the
emitted recipe changes (video codec, bitstream filter, tone-map mode or filter,
hardware backend), keeping segment reuse only for same-recipe backward seeks.
As defense in depth the throttler stamps each ffmpeg generation and refuses to
pause on — and resumes from — produced output older than the current process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): resume playback after a server-invalidated plan swaps the transport

A plan revision rebuilds the transport by tearing the previous source down with
`video.load()` in the outgoing effect's cleanup, and the media element load
algorithm is required to reject any play that is still pending with an
AbortError. The startup path latched `autoplayStarted` and dropped its readiness
listeners before awaiting `play()`, so that first rejection was swallowed by a
bare `.catch` and nothing ever tried again: the element sat paused on a healthy
buffer, the engine stopped fetching once it hit `maxBufferLength`, and the server
throttler paused the encoder behind a client that had gone silent. Autoplay is
now only latched once `play()` resolves, a rejection retries on a short timer as
well as on the next readiness event, and exhausting the budget logs the reason
and settles into a paused player with working controls instead of a dead one.
Direct play goes through the same readiness gate rather than calling `play()`
against a src still at HAVE_NOTHING.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): use native HLS for Safari HDR remuxes

* fix(playback): harden copy-remux startup

* fix(playback): address review findings on Safari native HLS remux

Apply five review findings on PR #653: tag jellycompat DV copy-remuxes
(dvh1 for profile 5/8 preserves) on both local and remote paths, surface
hls.js load failures before native fallback, restore the eager hls.js
chunk preload, drop the committed plan/spec working notes, and restore
the original_http stripped-HDR assertions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(playback): add tiered quality ladder

* fix(playback): prefer local hardware tone mapping

* fix(playback): preserve Intel tone-map brightness

* fix(web): avoid duplicate playback initialization

* fix(web): include subtitles in initial playback plan

* fix(web): show active quality selection

* fix(web): keep player menus above timeline

* fix(web): avoid duplicate audiobook playback starts

* fix(playback): boost stereo downmix loudness

* fix(playback): fence stereo downmix executors

* fix(playback): bind stereo downmix recipe shape

* fix(web): omit bitmap subtitles from the initial playback start

Bitmap (PGS/DVD/DVB) tracks have to be burned in on the web player.
Putting them on the opening plan forces a transcode, and HDR sources
refuse that start when tone mapping and 4K transcoding are off — the
defaults — leaving no stream to fall back to. Text subtitles still go
on the start request; bitmap selection is applied after a playable plan
exists.

Co-authored-by: Quick <Quick104@users.noreply.github.com>

* fix(web): recognize legacy bitmap subtitle codecs

* fix(web): avoid initial subtitle transport reload

* fix(playback): speed bitmap subtitle startup

* fix(scanner): clear stale skipped roots

* fix(web): disable native MKV playback in Firefox

* fix(playback): respect progressive audio codec scope

* fix(playback): choose audio adaptation per delivery

* chore(playback): deduplicate HLS adaptation reason

* fix(playback): preserve scoped audio invariants

* fix(playback): isolate remux delivery candidates

* fix(web): keep card menus visible without hover (#759)

* fix(playback): prefer source-preserving remux

* perf(playback): reduce startup latency safely

* perf(playback): reduce startup latency safely

* Revert "perf(playback): reduce startup latency safely"

This reverts commit 481dc4d3eb02b5e700ab5099bfe1146b3f7a284e.

* fix(playback): harden bitmap startup fallback

* fix(playback): harden startup latency paths

* fix(playback): complete startup shutdown hardening

* feat(web): improve poster card action controls

* fix(web): refine watched indicator placement

* feat(web): add watched shortcuts to media cards

* fix(web): correct compact card and overlay layouts

* fix(web): refresh card overlay defaults after save

* feat(web): add watched shortcuts to episode cards

* fix(web): show watched shortcuts for untouched episodes

* fix(web): address review feedback

* fix(web): support hybrid-pointer card actions

* refactor(web): centralize poster action sizing

* fix(web): address poster quick action review findings

Restore badge corner clearance for persistent card actions, preserve
in-flight optimistic watched/favorite state across parent re-renders,
align the Continue Watching hover dim with hybrid-pointer CSS, scope
collection invalidation by library, route the card menu admin gate
through useIsActingAdmin, dedupe the optimistic toggle handlers and
shared action icons, serve overlay config with private, no-cache
instead of a client no-store bypass, and add the feature changelog
entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: address code review findings

- avoid typed-nil localization repo interfaces in NewMetadataService
- export catalog.FitsPostgresInteger and drop the metadata duplicate
- extract generic bulkUpsertWithFallback for the five bulk-write fallbacks
- document the intentional exact statement-count pins in the query-count test
- fold the composited hover into the shared glass button variant, removing glass-static
- restore reduced-motion hover feedback on the Play button
- make ActionBar transition classes explicit per button

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(images): client-selectable artwork size via image_size (#742)

* feat(artwork): add a wide rung to the variant ladder and an imagesize seam

Posters and stills gain w780, logos gain w1280, so a client can ask for
artwork wider than the pre-existing default without falling back to the
original. Cast/crew profiles deliberately stay at w500/w300.

The new internal/imagesize package owns the client-facing size contract in
one leaf package: the image_size parameter, the four sizes, and the mapping
from a size to a cached variant. Small and large are derived from
artworkkey.VariantWidths; medium is pinned to the pre-image_size default so
an absent parameter and an explicit medium agree.

LadderVersion records the shape of the ladder so a later backfill can tell
that existing artwork predates the new rungs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): expose the image size capability and give jellycompat a large bucket

Adds GET /api/v1/images/capability so a client can discover the image_size
parameter and the pixel width behind each size instead of hardcoding them;
the widths are derived from the live variant ladder.

Jellyfin clients asking for 780-1199px now get the new wide rung rather
than being rounded down to the default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): let clients pick an artwork size with image_size

Catalog reads, item and watch detail, seasons and episodes, and the home
and library section endpoints accept image_size=small|medium|large|original.
The size applies to the whole response so a client never mixes resolutions
within one screen, and it overrides the per-context defaults including the
Continue Watching w1280 backdrop.

Without the parameter every path is byte-identical to before, which is what
every existing client sends. An unrecognized value is a 400 rather than a
silent fallback to the default size.

The parsed size rides on AccessFilter to reach the detail service helpers
that already carry it; imagesize still owns every decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(metadata): fall back to a narrower artwork rung when a new one is missing

Artwork cached before the ladder gained w780 posters/stills and w1280 logos
has no object at those keys, so a client asking for the large size would get
a 404 until the backfill catches up. The cached-key resolver now checks
whether a newly-added rung exists and walks down to one that does, ending at
the original.

Existence answers are cached — a day for present, fifteen minutes for absent
— so the check costs at most one HEAD per key per window. A check that errors
presigns the requested key optimistically and caches nothing, so brief
storage trouble cannot pin everyone to a narrow image. URLs served from a
fallback get a shortened lifetime so the real rung is picked up promptly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(metadata): regenerate cached artwork once per variant ladder version

Artwork cached before the ladder grew has no object at the new rungs, so
clients asking for the large size fall back until it is regenerated. A
one-shot pass re-enqueues already-cached poster, still, and logo artwork in
bounded batches after the ordinary queue drains, and records the ladder
version it finished in a new singleton table.

Interrupting it is safe: the pass resumes on the next scheduled run and only
a complete pass records the version. Re-running costs the source download —
the cacher skips uploading variants whose objects already match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(images): document the image_size parameter and the width ladder

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(api): record the images capability route in the manifest

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(api): thread the validated image size instead of re-reading it

Response builders took the request and re-parsed image_size per item. They
now take the size the entrypoint already validated, so what a response
renders cannot drift from what was checked. accessFilterOrDeny carries it
too, which is what the episode and season paths read.

Deletes three response helpers the compiler proved unreachable
(toEpisodeResponse, toEpisodeResponseWithFallback, toSeasonResponse). They
were already dead; the live paths go through episodeResponseShell and
toSeasonResponseFromEpisodes.

Also folds the capability handler's width parsing into an exported
imagesize.VariantWidthPx rather than a second copy of the same rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(api): honor image_size on favorites, watchlist, and history

The personal lists hardcoded their artwork widths, so a client that asked
every other list endpoint for a size got its preference ignored on the three
screens users open most.

They now take the same parameter with the same semantics: validated once per
request with a 400 on a bad value, and applied to every image in the
response. Sending nothing is byte-identical to before, including the
deliberate asymmetry between the featured poster and the card backdrop.

Episode entries resolve their still into the backdrop slot, so that path
follows the still ladder rather than building a backdrop-width key that was
never generated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(images): forward the large tier to plugin image resolvers

Plugin-resolved artwork collapsed large onto "featured", so an item whose
poster is hosted by a metadata plugin ignored the size a client asked for
while a cached one honored it.

PluginVariant now returns "large". No capability gate: the SDK's variant
field is an open string and first-party plugins fall back gracefully on a
name they do not recognize — tmdb and metadb to the original, tvdb to full
art — so a plugin built before this tier still returns a usable image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(images): name the image-type and variant vocabularies

CI's golangci-lint flagged eleven repeated string literals on this branch.
Rather than silence them one by one, the two vocabularies they belong to now
have names: artworkkey owns the image types its ladder is keyed by, and
imagesize owns the plugin-facing variant hints. jellycompat expresses its
size buckets with the imagesize constants, which is what they already were.

The ladder fallback stops spelling out the widths it checks. Each type that
gained a rung gained its widest one, so the check reads that back off the
ladder and cannot drift from artworkkey.VariantWidths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(images): resolve card backdrops on the ladder the path actually rides

A card's backdrop slot does not always hold a backdrop — an episode row puts
its still there. The card paths passed "backdrop" regardless, so a medium or
large request built a still/w1920 key the cache never generates and the URL
404'd. The slot now reads the type back off the key, as the featured backdrop
path already did.

Cast and crew headshots honored no size at all: they presigned the stored key
with a fixed hint. They now follow the profile ladder when a size is asked
for, and are byte-identical when one is not.

Also makes the cache task's progress monotone. A ladder pass in the same
execution reported 100 for the drain and then restarted at 0, which reads as
a failed-and-retrying task; the two phases now own ascending halves of the
bar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): decide ladder backfill completion from artwork, not job state

The sweep asked its own job table whether it was finished, and that answer is
wrong in four ways on a cluster. A bounded run recomputed its cutoff each
time, so a time-limited pass re-selected rows the previous one had completed
and could grind the same prefix forever. A node starting while another node's
batch was in flight saw nothing to do and recorded the version done — and if
that other node then died, the pass never resumed. During a rolling upgrade an
old-revision node could claim a job, write only the old rungs, and mark it
succeeded. And a job that exhausted its retries was parked out of view, which
also read as done.

Completion is now a question about storage: is any cached artwork still
missing the rung its type gained? It is answered from the artwork revision
manifest, which the cacher rewrites on every re-cache. All four failures
collapse — in each one the manifest is unchanged, so the row stays a
candidate and is simply picked up next time. The cutoff is gone entirely, and
the enqueue's dedup join goes back to being only what it says it is.

Sidecar (file://) artwork joins the sweep: the processor re-caches local
sources, so excluding them stripped those items of the new rungs for good.

Because completion is now measured against artwork that may be
unregenerable, the pass is paced rather than run every scheduler tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(metadata): resolve artwork ladder checks for a batch concurrently

A cold existence cache made every image in a list response wait on up to two
serial HEADs before the JSON could be written; a hundred-item browse page
turned that into seconds of latency in front of the response.

The ladder walk now runs for the whole batch with bounded concurrency, and
only for keys that can actually be missing — every other rung resolves
without touching storage. Presigning stays serial because it is local signing
work, not a round trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(images): honor image_size on audiobook groups and person artwork

Three surfaces disagreed with the rest of the API. /catalog/audiobook-groups
validated image_size and then ignored it, hardcoding the card variant.
jellycompat promoted a request to the large bucket on a height-only
constraint, but large names a WIDTH rung, so a portrait poster asked for at
MaxHeight=900 came back far taller than that. And jellycompat resolved person
headshots as posters, which since posters gained w780 meant building a
profile/w780 key that is never generated — a dead URL the ladder fallback
deliberately will not rescue.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(metadata): fence artwork ladder completion

* test(metadata): harden ladder completion coverage

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test(web): align ActionBar expectations with unified glass hover

The ActionBar tests added on main assert the superseded hover classes;
update them to the merged design (glass-hover overlay, motion-reduce
Play dim, per-button transition declarations).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: remove feature changelog requirement

- Remove the obsolete feature changelog
- Update repository guidance to require API docs only for contract changes

* fix(playback): surface 4K-transcode policy in errors and version fallback

With allow_4k_transcode off, the alternate-file fallback only excluded
siblings labelled "2160p", so a "4K"- or "UHD"-labelled version could be
offered as the fallback and refused for the same policy reason. The 4K
label test now lives in one exported helper (Is4KMediaFileV3) shared by
the planner and the fallback picker.

The web player replaced the server's terminal.message with generic copy
for no_alternate_version and the transcode-failure reasons; policy
refusals such as "A lower-resolution source is required because 4K
transcoding is disabled." now reach the viewer verbatim, with the old
generic sentence kept as the fallback for an absent message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): rank 4K alternate versions safely

* fix(web): stop carousel hover clipping and inset card progress bars

Carousel viewports use overflow-hidden flush against the cards, so the
media-card hover lift (translateY(-4px)) clipped the top of the hovered
card. Give each embla viewport 4px of top headroom (pt-1 -mt-1).

The watch-progress bar sat flush against the artwork's bottom edge; with
the default theme's near-white --primary accent a full bar read as a
stray white edge on the card. Inset it into a rounded pill across all
card/thumbnail surfaces so it reads as deliberate UI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): classify 8K under 4K policy

* fix(playback): exhaust alternate version candidates

* perf(web): split and precompress bootstrap assets

* chore(web): reuse content encoding constants

* fix(web): set compressed asset content length

* fix(web): honor encoding and loading semantics

* fix(web): preserve lazy player boundary

* fix(collections): restrict MDBList sync fetches to mdblist.com

Personal and admin MDBList import/sync fetched the caller-supplied list URL
with http.DefaultClient and no host allowlist, so any profile could make
the server GET loopback, link-local, or RFC1918 addresses.

Co-authored-by: Quick <Quick104@users.noreply.github.com>

* feat(web): optimize video player UI for mobile touch devices

On coarse-pointer devices the player now uses a centered mid-screen
transport cluster, a trimmed bottom HUD (captions, quality, overflow,
fullscreen) with 40px+ touch targets, bottom-sheet menus instead of
anchored popovers, tap-to-toggle controls with double-tap seek, and
hides the software volume control. Adds viewport-fit=cover and
safe-area insets for notched phones. Desktop (fine pointer) layout and
behavior are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): mobile layout fixes for item detail pages and seek bar

The pre-play Version/Audio/Subs selector row now wraps: unwrapped, its
min-content width inflated the auto-sized hero column past narrow
viewports, clipping the overview text and selectors on movie and
episode pages. The compact detail hero (season pages) uses min-height
below lg so bottom-justified content grows the hero instead of
overflowing out the top under the floating back button. The player
seek bar shows its thumb and thicker track on coarse pointers, where
hover reveal never fires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): hide episodes of disabled dual-library series (#762)

* fix(catalog): hide episodes of disabled dual-library series

PR #738 made any disabled-library membership hide a series from browse
and detail, but search and episode rails still keyed off episode_libraries.
Episodes whose files lived only in an enabled library kept appearing, then
404'd on open. Apply the same parent-series allow/deny predicates those
detail and playback paths already use.

Co-authored-by: Quick <Quick104@users.noreply.github.com>

* perf(catalog): reuse episode parent series IDs

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Quick <Quick104@users.noreply.github.com>
Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>

* docs: update Discord invite link (#777)

* docs: use HTTPS for Discord invite (#778)

* fix(collections): align template URL validation

* feat(branding): support light/dark theme logo variants (#779)

* feat(branding): support light/dark theme logo variants

Light-theme users previously got the white-text wordmark, which is
invisible on light surfaces. Themes now declare an appearance, SiloBrand
picks the bundled dark-text wordmark on light themes, and admins can
upload optional light-theme variants of the custom wordmark and mark
(new wordmark_light/mark_light asset kinds, additive API fields).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* style(web): format BrandingAssetKind union per prettier

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(web): replace touch card overlays with long-press action sheet (#781)

* feat(web): replace touch card overlays with long-press action sheet

On touch devices media cards showed every overlay control at rest —
center play button, watched/favorite quick actions, and the three-dot
menu — cluttering the artwork. Hide them at rest and open the same
action model from a 500ms long press as a bottom sheet instead,
matching the native clients. Hover reveal is unchanged for fine
pointers, and keyboard focus still reveals the controls on every
device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(web): address card action sheet review findings

Use the resolved caption title for the episode action-sheet heading on
SectionItemCard, and reuse hasPartialProgress for the SeasonEpisodeGrid
progress bar instead of repeating the condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(catalog): bound search outbox retention

* feat(taskmanager): add bounded task history retention cleanup

- Prune execution history by age and per-task retention limits
- Coordinate cleanup across nodes with a PostgreSQL advisory lock
- Register the scheduled cleanup task and add coverage

* refactor(taskmanager): address review findings on history retention

Rework the task history retention cleanup added in this PR after review.

- Replace the whole-table window ranking in the prune query with a
  per-task-key boundary design. Each key resolves its newest row and its
  keep-boundary row from idx_task_executions_key_completed, and deletes run
  against a tuple-comparison predicate that is provably equivalent to the
  previous recent_rank CTE (pinned by a test that diffs both doomed sets).
  A run with nothing to delete no longer sorts the entire table, and no
  longer does so once per batch. No new migration.
- Report LimitReached only when doomed work actually remains. Loop
  exhaustion alone used to claim a truncated run whenever the doomed count
  was an exact multiple of batchSize.
- Extract the session-level advisory lock into internal/database/pglock and
  use it from both Prune and the catalog search indexer, deleting
  SearchIndexAdvisoryLock. The shared helper hijacks and closes the
  connection whenever an unlock cannot be confirmed, which also fixes the
  catalog helper's unconditional Release on unlock failure.
- Drive retention from server settings (taskmanager.history_retention_days,
  default 30; taskmanager.history_keep_per_task, default 1000) the way the
  activity and operational log cleanups do: seeded at boot, read per
  Execute, out-of-range values falling back to the defaults. Batch geometry
  stays a compile-time constant.
- Log an info line when a run is skipped because another node holds the
  lock, so a skip is visible outside result_data.
- Drop the taskHistoryCleanupResult mirror struct and tag
  taskmanager.HistoryPruneResult directly; drop the nil-progress guard and
  the unreachable limit validation in Prune; unexport taskHistoryPruner.
- Rewrite the repository tests against the migrated SILO_TEST_DATABASE_URL
  database instead of a hand-copied throwaway schema, with unique task keys
  and cleanup deletes, and cover the keepPerTask boundary, tie-breaks,
  cutoff rank-1 preservation, and the exact-multiple LimitReached case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(catalog): persist Watchlist and Favorites browse sort per profile

Sort choices made while browsing a library or user collection already
survive leaving and re-entering the view, but the two personal lists did
not: Watchlist and Favorites reset to their default order on every visit.

Widen the existing profile-scoped sort preference storage to cover them
instead of adding a parallel mechanism. `collection_kind` gains
`watchlist` and `favorites`; because neither has a collection resource id
of its own, both store under the sentinel `collection_id` "personal".

Personal lists validate against the same non-personalized vocabulary the
live personal browse accepts (NormalizePersonalSourceSort), so `progress`,
`date_viewed`, `plays`, `relevance`, and `rand…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant