Skip to content

Changelog

All notable changes to the Ctrl-Audio platform are documented here.

Format follows Keep a Changelog.


[Unreleased]

Added

  • doc-56 S2 built — first-play made structural (2026-07-06, Claude Code; owner "ok to continue"; xcodebuild green, ⏳ on-device QA):
    • Route-enforced seeds (§4.1-B): TrackRoute's memberwise init is now private — surfaces route through TrackRoute(track:) (seed-complete by construction: name/thumb/audio/duration from the list Track) or the loud .coldOpen(id:surface:) escape hatch; a partially-seeded route can't be hand-rolled anymore (how the June mechanism silently decayed). Prewarm moved out of per-row simultaneousGesture sprinkles into the navigation layer: TrackLink (rows) + NavigationPath.push(_:) (imperative pushes) — all 7 call sites migrated.
    • Likelihood-prewarm (§4.2-B): when the dashboard payload lands, the top-2 candidates (Resume tile + first approval row) prewarm speculatively — the first play of the install is warm before the tap (this is also the TLS preconnect). NWPathMonitor guard: on expensive/constrained paths speculative preroll caps at ~5 s (preferredForwardBufferDuration); tap prewarms stay unlimited (expressed intent).
    • Seeded transport (§4.3): bottomGlassStack (composer + PlayerBar) now renders from route seeds before /track/:id returns — play is pressable from the first frame; detail fills title/version in place, nothing pops. Composer no-ops safely pre-detail (postComment guards).
    • playImmediately(atRate: 1.0) (§4.3): user-initiated play (playPause + version-switch resume) starts the instant frames exist instead of waiting for the playthrough prediction.
    • Pending from S2 scope: the Front Door-over-audio-blobs spike (§4.4-D) — infra config + the doc-56 §7 Q4 egress-cost decision, owner's court. S3 (disk cache) + S4 (renditions decision) remain.
  • doc-56 S1 + doc-57 P0 built — the iOS half (2026-07-06, Claude Code; owner greenlight; xcodebuild green, ⏳ on-device QA. The backend half landed earlier in b419748: lean audioFinalFile refs on approvals + recents, toAudioFinalFileRef + unit tests, the play:first-audio event type.)
    • Cold surfaces covered (doc-56 §3.1): Home approval rows — the Review & Approve MVP path, the audited coldest first play — now seed + prewarm from the dashboard's audioFinalFile (mirroring the Resume tile); project-portal → track resolves the already-loaded row and seeds + prewarms; artist-portal → track stays an honest cold-open, labeled (surface: "artist.portal") so it shows in the TTFA chart — no track data exists at that call site; warming it means carrying an audio ref through the portal rollup (S2 candidate).
    • Duration seeds end-to-end: TrackAudioFinalRef + Track.audioFinalFile decode duration; TrackRoute.audioFinalDurationseedAudio(knownDuration:) — the timecode is right on the first frame, and seeded opens skip the network asset-duration probe.
    • TTFA metric (doc-56 §5): play:first-audio fires once per player session on the first .playing transition — {ttfaMs (route→sound), sincePlayMs (press→sound, excludes think-time), seeded, prewarmed, surface, fileId}; every TrackRoute call site now carries a surface label (home.approval / home.resume / home.newversion / project.row / project.portal / artist.portal). DEBUG builds print a [TTFA] line on every first play — the doc-56 HUD v1.
    • doc-57 P0 — lock-screen artwork: MPMediaItemArtwork fed from the cover cascade (detail cover medium → seeded track thumb → project image) through DecodedImageCache/URLCache and included in Now Playing — the lock screen / Control Center show the cover for the first time.
    • QA crash fixed same day (owner run: freeze, then 'An AVPlayerItem cannot be associated with more than one instance of AVPlayer' opening a track from Home): AudioPrewarmer.take() moved the actively-loading prewarmed item between players — detach→attach races AVFoundation's internal association and throws intermittently. A latent June design flaw S1 exposed (the adopt path had never executed before — seeds were silently nil, per the corrected doc-56 §2 narrative). Fix: take() now returns a fresh AVPlayerItem over the warmed AVURLAsset — keeps the resolved header state + hot connection (the cold-TLS cost prewarm exists to kill); the item-level buffer re-pulls over the warm connection (bounded re-fetch, never a crash). xcodebuild green; ⏳ re-QA.
  • doc-57 Player as Product envisioned (2026-07-05, Claude Code; docs only — awaiting owner greenlight; four forks pre-marked with the owner same day). Owner + stakeholder extended the player vision: Sonnance as the player of your own music and the place to verify what would publish — without leaving the app. infrastructure/57-PLAYER-AS-PRODUCT.md: handoff = browse ≠ play ✅ (opening track B while A sounds loads B paused with a prominent play; mini player/lock screen always show what's sounding; explicit play is the only switch — the loaded-paused deck literally is doc-56's prewarmed item, same machinery seen twice); favorites queue ✅ (dedicated Favorite collection, unique (user, resource), track-level hearts v1 → audioFinal, recency queue = doc-30 Phase 3's queue source; named playlists deferred); release-preview parity — lock-screen artwork via MPMediaItemArtwork (P0: nothing sets artwork today; hours-sized, gate-free, greenlightable alone), motion artwork player-side (Apple spec recorded: dual 1:1 3840² + 3:4 2048×2732, seamless loop, first frame = still cover, EP/Album/Artist pages only → a Project/ArtistSpace asset in the doc-45 cloud ✅; iOS 26 MPMediaItemAnimatedArtwork makes the full-screen animated lock-screen preview literal for third-party apps), line-synced lyrics ✅ (structured Lyrics collection + tap-along sync editor on the existing scrubber + Apple-style scrolling view + TTML/LRC export; explicitly not a doc-32 approval dimension); Musixmatch: replicate, don't integrate ✅ (paid catalog-display API, no artist OAuth — and our artists own their lyrics; publishing path documented: Apple ← TTML via distributor/iTunes Connect, Spotify/IG ← Musixmatch Pro). Full-screen artwork/lyrics = a new explicitly-entered "listening mode" — the doc-22/24 waveform-centric lock stands. Slices P0–P4; P1+ gated behind doc-56 S2 + doc-30 Phase 2. Housekeeping: doc-56's broken 30-FAVORITES-OFFLINE.md link fixed; doc-56 + doc-57 rows added to the doc-00 map (56's was missing); pointers added in docs 30 / 24 / 45 / 32. Updated 2026-07-06 (owner): all six §9 questions resolved — favorites entry on Home (Resume area); version/variant switch is browse ≠ play too → the player/mini/lock screen now show the sounding version #, reversing today's immediate-switch (fast A/B preserved via a per-VersionCard play control); lyrics editing = edit-rights + admin (RBAC later); lyrics approval waits for the LEGO/workflow layer (doc-43/46); Musixmatch guidance docs-only pending stakeholder review; cover-model unification = own slice. Plus owner-raised whole-album play + repeat/loop: §4 reframed so the queue is first-class with sources {album/project ✅ fundamental, favorites, single} + modes {repeat off/one/all, shuffle} — album play rides existing Track.order + doc-56 S1's audio ref → no new backend, so it leads. Slices reordered: P2 Queue & modes (client-only) → P3 Favorites; P2+P3 realize doc-30 Phase 3. Locked 2026-07-06 (owner: "good to go"): the final two items confirmed — play-jump = jump the queue (play on a track inside the sounding album jumps to it; outside starts a fresh queue) and shuffle stays in the v1 mode set. doc-57 is now fully resolved envisioning — nothing open; build follows §8 sequencing (P0 lock-screen artwork is gate-free and ready; P1+ await doc-56 S1/S2 + doc-30 Phase 2, each with its own greenlight).
  • doc-56 First-Play Pipeline envisioned (2026-07-05, Claude Code; docs only — awaiting owner greenlight). Owner re-raised first-byte: "still laggy and flashy on first load" (fresh install + ~4-min song) despite the June fixes. Audit initially read the June mechanism as "held but decayed"; the S1 build sharpened it: it never fired at all (doc-56 §2) — the doc-24 backend half (final audio ref on list payloads) was never built, and the iOS decode expected a single File object where the API always sent the audio array, so the permissive try? nil'd silently → prewarm + seeds no-oped on every surface since June (the felt improvement was the skeleton/de-hitch/duration work). On top of that, coverage decay: 3 of 7 player entry points never wired it — including the Home approval rows (the Review & Approve MVP path), where the dashboard's owned/shared queries don't populate audio (while buildPendingApprovals already fetches the under-review file and drops it). Plus: transport bar gated on /track/:id (can't press play → pops in), seeds carry no duration, no audio disk cache (SAS lasts 1 year — reuse is free), no TLS preconnect, and no TTFA metric so the regression was invisible. infrastructure/56-FIRST-PLAY-PIPELINE.md envisions the fix as a system: Intent → Warm → Paint → Sound; route-enforced seeds/prewarm (TrackRoute(track:) sole entry), likelihood-prewarm of the top-2 dashboard candidates, seeded transport (play before detail), fileId-keyed LRU disk cache, renditions/Front Door as data-driven forks — locked by a doc-38 playback_first_audio metric with budgets (warm P50 ≤ 300 ms; fresh-install Wi-Fi P50 ≤ 1.5 s). Slices S1–S4 defined; S1 (cover the cold surfaces + record TTFA) is small and hits the owner's literal scenario. doc-24 §Performance now points here.
  • Legacy attachment backfill + files-as-LEGO-attributes envisioned (2026-07-05, Claude Code; script typechecks clean + nest build green):
    • scripts/backfill-attachment-fields.ts — dry-run by default (APPLY=true to write); forward-fills folder + resourceType:'track' onto pre-existing track attachments by walking each track's embedded attachments map → the canonical File doc (only when missing; idempotent, orphan-ref safe). Brings legacy files into the doc-45 roll-up grouped + tier-chipped, like new uploads. Owner runs it when ready (mirrors the repo's other backfill scripts).
    • doc-43 §3 extended — file is a first-class attribute node (owner-raised): File.metadata becomes the file attribute bag; built-in file facets ship as presets (kind audio/video/image/ attachment · attachment folder · MIME/format · size · duration · version · review status); custom file attributes ride the workspace overlay (e.g. "stem instrument", "contract status"); FileType/ folders stay extensible per doc-45. Makes the asset cloud a fully-attributed layer.
    • doc-40 extended — files & attachments are a filter/search/sort dimension (owner: "filter & search will require it too"): filter files by kind/folder/format/size/review-status + any doc-43 custom attribute; search by name (backend search module extended to File at scale); the roll-up view is where filtering pays off ("only Stems across this project", "every unsigned contract in the studio drive"); a Saved View here = "the Contracts lens" (doc-26).
  • doc-45 step 3 — aggregate-view-up (the cloud "mapped with the hierarchy") SHIPPED, full stack (2026-07-04, Claude Code; nest build + e2e 5/5 on attachments.e2e-spec · xcodebuildBUILD SUCCEEDED). The owner's literal ask — "the document library… mapped with the current hierarchy" — now real: a higher node's Files zone shows its descendants' attachments too.
    • Backend: AttachmentService.findForArtist/Project/Workspace gain a rollup flag + ?rollup=1 on the three GET endpoints. Cheap ancestor-ref query (dropping the resourceType filter): project roll-up = files where project == id (own + tracks); artist = artistSpace == id (own + projects + tracks); workspace = spaceOwner == id (the whole studio drive) — because track/ project attachments already carry the ancestor refs. A read lens, not a move (doc-45). Integration test asserts a track-level stem surfaces in both the project and artist roll-ups.
    • iOS: MediaFile gains resourceType (+ attachmentTier, legacy → 'track'); PortalFilesSection rows carry a tier chip (Artist · Project · Track · Studio) when the file lives below the node — the node's own files stay clean. Artist / Project / Workspace portals fetch ?rollup=1, so each cloud now shows everything beneath it, folder-grouped, source-chipped. (Track = leaf, no roll-up; keeps its embedded-map zone.)
    • Remaining on the thread: "Add file" in the Home "+" · legacy track backfill · Track Files-zone dedupe onto the shared section · pagination when clouds get large.
  • Files zones at Artist / Project / Workspace SHIPPED (iOS) — the asset cloud visible at every altitude (2026-07-04, Claude Code; xcodebuild BUILD SUCCEEDED). Consumes the doc-45 step-2 backend; closes the owner's original ask ("a document library related to the element, mapped with the hierarchy"):
    • New shared Features/Browse/PortalFilesSection.swift — the Files-zone body for the non-track altitudes (folder-grouped rows · type icons · size · signed-URL open · in-zone Add) + shared helpers (attachmentFolderGroups/Choices, type icon). MediaFile gains folder. Plumbing (folder dialog · document picker · upload) stays at each portal so the foot "+" shares the flow when the zone is hidden (doc-54 §3.3). Track keeps its shipped embedded-map variant (dedupe = later polish).
    • Artist Portal — self-fetches /artist/:id/attachments; "Files (N)" zone after the artist image; foot "Add file" when empty (owner/admin).
    • Project Portal — attachments join loadDetail; zone after Workflow; the foot now composes "Add cover" + "Add file" via footAddItems (PortalAddFoot's multi-item menu grammar in use).
    • Workspace Portal — the "studio drive": 5th parallel fetch; zone between Studio defaults and Roster; foot composes "Create your first artist" + "Add file". Default folder suggestion = Contracts (studio-level docs).
  • doc-45 step 2 BACKEND SHIPPED — attachments at every node (the asset cloud generalized) (2026-07-04, Claude Code; nest build clean + full e2e suite green, 4 suites / 13 tests incl. the new test/attachments.e2e-spec.ts). The greenlit next pass:
    • Model: File(type:'attachment') gains folder and resourceType ('track' · 'project' · 'artistSpace' · 'workspace'); legacy track attachments (no resourceType) read as 'track'; new track uploads forward-fill both fields (embedded-map backfill = later pass). Workspace attachments key on spaceOwner (the per-owner tenant, doc-55) — FileService.create now tolerates a missing artistSpace (quota falls back to the owner).
    • New file/attachment.service.ts + endpoints: GET/POST /artist/:id/attachments · GET/POST /project/:id/attachments · GET/POST /workspace/attachments (the "studio drive", owner-scoped) — same multipart + folder contract as the track endpoints; blob keys node-derived via createNodeAttachmentLink (files-ride-the-node re-key constraint stands, doc-45).
    • doc-44 milestone: these are the first routes consuming PermissionService.can() — reads gate on View, writes on Upload, project chains walk project → artist, default-deny with the legacy owner/collaborator fallback (integration test asserts a stranger is 403'd).
    • Unlocks next: iOS Files zones at Artist/Project/Workspace (reuse the shipped Track UI pattern) · "Add file" in the Home "+" · step-3 roll-up views.
  • Portal foot-"+" ROLLED OUT across the ladder (iOS) (2026-07-04, Claude Code; xcodebuildBUILD SUCCEEDED). doc-54 §3.3 applied per altitude after a zone audit:
    • Workspace Portal — Roster zone renders only when artists exist; an artist-less studio gets a dashed "Create your first artist" foot slot (create sheet → lands on the new artist in Browse) — the §3.7 "studio root onboards itself" promise delivered.
    • Project Portal — ALBUM ART renders only when a cover exists (no more "No cover yet" dead weight for viewers); foot "Add cover" reveals the art tools in place (offer-then-expand joins direct-action as the second foot grammar).
    • Artist / File Portals — no change by design (audit recorded in doc-54 §3.3): Artist's roster/tag are identity-metadata rows, not zones; the File leaf's only growable content is anchored Notes, whose emptiness isn't parent-knowable until doc-33 exposes counts — the Notes zone header stays the leaf's add affordance meanwhile.
  • doc-55 resolutions locked + portal foot-"+" reference BUILT (iOS) (2026-07-04, Claude Code; xcodebuild BUILD SUCCEEDED).
    • doc-55 §6 — all four resolved by owner: facets private by default on the card (doc-53 zero-state) · multiple facets per membership allowed (facets[] plural — one membership, one role, many hats; e.g. artist and engineer at your own studio) · guest→member keeps the relation (person-layer history rides the upgrade, doc-41 continuity) · workspace switcher = the reserved Home top-left corner (doc-54 §8.2 pointer added).
    • Portal start-almost-empty, reference implementation (doc-54 §3.3 / doc-49 §7 fork 4): new DesignSystem/Components/PortalAddFoot.swift — the dashed "open slot" foot affordance (one unfilled zone = direct button; several = one "+ Add" menu). Applied on the Track Portal: the Files zone now renders only when it has content; when unfilled, the foot offers "Add file" (folder confirmation dialog → document picker; uploading/error states surface at the foot). Roll-out to Artist/Project/Workspace/File Portals + further parent-knowable zones = next slices.
  • Contact tab shell BUILT (iOS) — name locked "Contact", avatar tab icon; owner QA passed (2026-07-04, Claude Code; xcodebuild BUILD SUCCEEDED). Owner resolutions: §8.3 Option B (zoned tab) ✓ · organizers path A ✓ · name = "Contact" with the user's avatar in a circle as the tab icon ("the icon addresses the ambiguous tab better, at least for now"; fallback person.crop.circle). Device QA passed for Home v2 + Files zone + "New version".
    • New Features/Contact/ContactTabView.swift — the identity corner as a 5th tab (extreme right), doc-47 §10.3 realized: Identity zone = the Contact ID seed card (avatar · name · email · sonnance.com/@username when set — doc-53's card grows into it) · absorbed account duties (appearance · sign out — moved out of AppMenuDrawer, which slims to Library overflow [Shared/Recents] until those rehome) · Discovery corner (globe, top-right → honest placeholder until doc-52 events exist). Contacts/Messages/Discovery zones render only when content arrives (principle 7).
    • RootTabsView: AppZone.contact + TabAvatarLoader (circular-masked, original-rendering avatar bitmap for the tab bar; falls back to the symbol). QA checklist in .agent/next-brief.md; prior Home-v2/Files/"New version" checklists marked passed (owner).
  • Files zone (doc-45 step 1) + "New version" in the Home "+" BUILT (iOS); tenancy/personas envisioned (doc-55) (2026-07-04, Claude Code; xcodebuild BUILD SUCCEEDED). Owner's greenlight-round gaps closed:
    • Track Portal Files zone SHIPPED — the node's document library (doc-45 asset cloud, track altitude): TrackDetail now decodes the folder-structured attachments map (lenient); a "Files (N)" PortalZone lists Lyrics/Stems/Contracts + custom folders (non-empty only, principle 7) with type-iconed rows opening the signed blob; Add file = folder menu → document picker → multipart POST /track/:id/attachments (+ folder part) → detail refetch. Parent-owned callback pattern (PlayerViewModel.uploadAttachment), mirrors uploadCover.
    • "New version" SHIPPED into the Home "+" (owner-flagged gap): HierarchyCreateSheet grew a unified result + track-pick step (artist → album → track); TrackRoute.presentUploadVersion carries the intent; PlayerView auto-presents the existing UploadVersionSheet once detail lands; AppRouter.openTrackForVersionUpload deep-links. "Add file" joins the "+" once doc-45 step 2 (any-node attachments) exists — noted in doc-54 §8.1.
    • doc-45 updated — step 1 marked shipped + two owner rulings: files ride the node (a node move carries its files; storage re-key constraint flagged for step 2) · Workspace cloud lives in the Workspace Portal's Files zone (Configure ▸ Workspace); roster stays a lens (aggregate-view-up filter, no cloud of its own).
    • doc-54 §3.1 gains the tile contract (per tile: on-tile references · drill-in content · source; tiles never grow the screen — displacement by priority) — answers "confirm/envision/align the tiles."
    • NEW doc-55 55-TENANCY-PERSONAS.md (envisioning) — Workspace = tenant (three sanctioned crossings: migration/move · the person layer · link/guest principals) + one person, many hats: sub-accounts rejected; one User + one Contact ID + WorkspaceMembership(role, facet), the card wears the hats under per-facet visibility lenses. Roadmap §8 row added.
  • Home v2 BUILT — non-scrollable one-screen dashboard + whole-hierarchy "+" (iOS) (2026-07-04, Claude Code; xcodebuild BUILD SUCCEEDED). Implements the greenlit doc-54 §3.1/§8.1 slice:
    • DashboardView is no longer a ScrollView — a fixed one-screen canvas: brand header · pipeline strip (horizontal chips stay — the philosophy bans vertical scroll) · a 2-column attention tile grid replacing the stacked row lists. Tiles (render only when non-empty, priority order): Approvals (role-aware title, count + top item) · Resume (new context-aware continuity tile — last-opened track w/ artwork, zoom transition + audio prewarm + hierarchy long-press; closes the doc-47 §11 field feedback) · Open notes · Activity (unread-count aware). Row lists moved wholly into the See-all screens — the only scroll escape. AX policy stated in code: accessibility Dynamic Type sizes re-enable scrolling; Home reloads on tab-return (pull-to-refresh only in the AX fallback). New DesignSystem/Components/DashboardTile.swift (shares the pipeline-chip card grammar).
    • The trailing "+" is now the whole-hierarchy create menu (doc-37 §5 queued item, owner-confirmed dimensions): New artist / New album / New track — album/track run a pick-parent flow (new Features/Shell/HierarchyCreateSheet.swift: artist → [album] pickers, then the existing create sheets), landing on the new node in Browse via two new AppRouter deep-links (openProjectInBrowse, openTrackInBrowse). "New version" joins when audio upload lands from Home (doc-25 staged) — the menu grows additively.
    • Visual QA checklist left in .agent/next-brief.md (layout-critical routing rule).
  • UX/UI review — start simple, many ways of working (doc-54) (2026-07-04, Claude Code; docs-only pass). Owner + stakeholder session consolidated into 54-UX-REVIEW-START-SIMPLE.md: two principles locked into doc-47 §7 (7 start almost empty"the minimum that provides basic/common value as a starting point"; empty structure never renders · 8 many ways of working — structure offered, never demanded) · Home v2 = non-scrollable one screen (attention tiles + See-all drill-ins as the only scroll escape; new context-aware Resume tile; budget-verified ~400–430pt; AX Dynamic Type exception stated — resolves doc-47's logged field feedback) · new Contact tab (5th tab, extreme right; naming open): Contact ID + account + Discovery entry top-right (absorbs AppMenuDrawer) · doc-49 §7 fork 4 resolved (portals start almost empty: no-content zones don't render; one "+" foot affordance) · doc-43 sharpened (quick-add "+" = the primary authoring; minimal preset default; free-tier preset picks; [untitled] = the doc-15 competitor as the start-easy bar) · progressive Configure onboarding (doc-48; no wizard) · mgmt.ctrl confirmed under pluralism (no doc-42 change) · Event = card + sheet (PortalZone-compatible), no v1 Portal + contact↔event cross-refs (attending · registered · attended) into docs 52/53. Build order proposed (Home v2 → Contact tab shell → portal empty-state → LEGO "+"), owner greenlights before any code.
    • Owner decisions resolved (answers the 2026-07-03 entry's ⚠️): reference_assets/ committed and pushed as-is (doc-51 note closed) · handle sonnance.com/@username approved (doc-53 §6 Q1, pending doc-21 namespace reconciliation).
    • Cross-refs updated: pointer blockquotes in docs 24 · 37 · 43 · 47 · 48 · 49 · 50 · 51 · 52 · 53; 00-ROADMAP.md §8 row (re-aligned 2026-07-04).
    • ✅ GREENLIT + addendum (doc-54 §8, same day): owner greenlit the build order and added design input, captured as options w/ pros-cons — Home "+" = whole-hierarchy create menu (confirms doc-37 §5; hierarchy-only v1, grows additively) · Home top-left = nothing (avatar interim until the Contact tab) · identity-corner inner IA: zoned tab recommended (Option B — Identity · Contacts · Messages · Discovery zones, start-almost-empty; owner's corner-entry sketch retained; Messaging threads + contact list situated → docs 52/53 pointers) · tab-name candidates + Me / World · event organizers = same app, role-driven (doc-44 event-scoped grants; Hosting lens + Contact ID hosting relation; mgmt.ctrl events lens as growth path — never a separate app). Slice 1 (Home v2) unblocked; slice 2 (Contact tab) waits on the §8.3 shape pick.
  • Release vision registered — Contact ID + Discovery/Events elevated to headline features; Apple Invites UX study (2026-07-03, Claude Code; docs-only pass). Owner + stakeholder: users need features available, not a half-done product — first-release framing upgrades MVP → Release vision (loop + two headline features). Three new docs:
    • doc-51 51-INVITES-UX-REFERENCE.md — the owner's 38 Apple Invites screenshots (infrastructure/reference_assets/, previously unreferenced) fully indexed + 20 stable patterns INV-01…INV-20 with Adopt/Adapt/Skip verdicts (card anatomy · pinned RSVP · timespan/role lenses · dual-mode inviting · link expiry/rotation/kill-switch · QR-as-peer · tiles link-hub · asymmetric visibility · per-object privacy panel · minimal contact consent · pre-permission pitches). ⚠️ Owner decision needed: reference_assets/ ≈ 77 MB, untracked — Git LFS / commit / local-only.
    • doc-52 52-DISCOVERY-EVENTS.md (envisioning) — first dedicated Discovery/Events doc; the Event = timespan-bound node with momentum stages (draft→announced→approaching→live→afterglow→ archive) leading with a request-for-action (RSVP); consolidates + supersedes the doc-17/15 fragments (Event Organizer actor, DiscoveryFeed/MapView, Maps, Discovery Agent); sharing rides doc-26 links (+ the new one-time per-guest link idea); Event-as-Portal fork left open.
    • doc-53 53-CONTACTID-RELEASE.md — Contact ID release-era spec, supersedes doc-16's web-first framing (card visuals stay reference): fills the named gap of an identity privacy philosophy (owner-private by default · opt-in per field · outsider view = configured lens — extends doc-38/26 posture to people) + capabilities (graph-gated messaging · contacts graph w/ "who has me" only-me · opt-in graph-derived stats · Apple Wallet .pkpass QR card · Linktree-style tiles).
    • Cross-refs updated: 00-ROADMAP.md (re-aligned 2026-07-03: §5 Release-vision blockquote, §7 Release-headline slot, §8 map rows 51–53 + doc-16 note) · doc-47 §10.3/§11 pointers → docs 52/53 · doc-16 banner → doc-53 · infrastructure/README.md index rows (+ pointer to the full §8 map).
  • doc-45 asset cloud envisioned + Workspace Portal actionable (iOS) (2026-06-30, Claude Code; xcodebuild BUILD SUCCEEDED). Two owner observations: "where do I find the files/folders cloud per hierarchy node?" and "the Workspace Portal shows rosters but has no actions."
    • Grounded finding + doc-45 design passTrack attachments already exist (Track.attachments = folder-structured Lyrics/Stems/Contracts + custom; GET/POST /track/:id/attachments) but have no iOS surface — that's why files felt missing. Nothing at Project/Artist/Workspace. Added a "asset cloud across the hierarchy" section to doc-45: one node-attached File(type:'attachment', resource, folder) model at every altitude, surfaced as a "Files" zone in every Portal (doc-49); attach-at-node with aggregate-view-up (a Project/Workspace cloud rolls up descendants). Owner chose envision-first; smallest first build = surface the existing Track attachments. (The File Portal ≠ the cloud — it's a single version's detail.)
    • Workspace Portal — first action (doc-50 action-first): the Roster zone's artists are now tappable → open in Browse (router.openArtistInBrowse), so you can drill from the studio root down into an artist. (Manage-default + create affordances are noted next.)
  • doc-49 slice 5 — the File Portal: the leaf altitude (iOS) (2026-06-30, Claude Code; xcodebuildBUILD SUCCEEDED). The fifth and final altitude — every node in the hierarchy now has a Portal: Workspace → Artist → Project → Track → File, all the same PortalZone grammar, top to leaf.
    • New Features/Review/FilePortalContent.swift — a single version/asset's detail: Identity (Version N + name + review pill + FINAL badge) · Asset (image preview for covers + metadata: version · type · length · size) · Review (doc-29 status, read-only — acting stays in the player's approve bar, so the leaf doesn't duplicate the write path) · Notes (.file-anchored, doc-33, collapsed).
    • Entry: long-press a version in the player's version switcher"Version details" → the File Portal (deferred sheet-swap to avoid racing two sheets). No new backend (uses MediaFile already in TrackDetail.audio[]). Composed only from DesignSystem.
    • 🎉 The Portal fractal is fully complete — all five altitudes share one anatomy. Remaining doc-49 polish: full zone-header normalization + extract shared zone components · more discoverable File-Portal entry · doc-28 variant authoring · asset cloud (doc-45).
  • doc-49 slice 4 — the Workspace root Portal: the "cloud root space" (iOS) (2026-06-30, Claude Code; xcodebuild BUILD SUCCEEDED). The last altitude — completes the fractal Workspace → Artist → Project → Track. The studio's root, the "cloud space" the owner asked about, built from the same PortalZone anatomy.
    • New Features/Configure/WorkspacePortalView.swift — a full-screen Configure destination (not a sheet, doc-49 §7 Q1) reached from Configure ▸ Workspace (flipped from Soon → live). Zones: Identity (studio name) · Portfolio (a KPI grid — artists · projects · tracks · to-approve · ready · open-notes) · Studio defaults (the doc-48 default workflow name + where to change it) · Roster (collapsed — rosters with artist counts + names, the roster-as-lens at the root).
    • No new backend — composes /management/overview + /workspace + /workflow/templates + /management/matrix (parallel best-effort). Clarifies doc-48/49: Roster = a lens shown inside the Workspace Portal, not its own portal.
    • Fractal complete: all four object altitudes now share the zone anatomy. Remaining: full zone-header normalization + extract shared zone components · File Portal · doc-28 variant authoring · asset cloud (doc-45).
    • Fixed a Swift type-check timeout in studioName (de-sugared the nested optional/closure chain).
  • doc-49 slice 3 — Track Portal onto the anatomy + doc-28 variant chips surfaced (iOS) (2026-06-30, Claude Code; xcodebuild BUILD SUCCEEDED). Two wins in one. Audit correction: the Track Portal did exist (Features/Review/TrackPortalContent.swift, presented from the player) — the doc-49 audit missed it (only surveyed Browse/); it was the same flat wall.
    • Re-grouped onto the anatomy — Identity (header + metadata) + Content (cover + label copy) visible; Variants & links · Sharing · Notes · Manage collapsed (progressive disclosure).
    • doc-28 variant chips — finally on iOS (the long-standing "iOS not yet"): a new Variants & links zone self-fetches GET /track/:id/relations and shows the variantType chip (Original / Radio Edit / Remix / …), cross-links (Remix of: …, Alternate of: …, with the related track's variant tag), and "Also on: {album}" multi-album appearances. New Core/Models/TrackRelations.swift + TrackVariantsView (read-only chips; authoring/tap-to-hop is a later doc-28 slice). Composed only from DesignSystem.
    • Fractal status: Project · Artist · Track now share the zone anatomy; Workspace root is the last altitude (+ full zone-header normalization · File Portal).
  • doc-49 slice 2 — Artist Portal onto the anatomy (iOS) (2026-06-30, Claude Code; xcodebuildBUILD SUCCEEDED). Rolled PortalZone to the Artist Portal — same clean grammar as Project: Identity (header + metadata) + artist art visible; Team & sharing · Notes · My tag (viewer) · Manage collapsed. Stripped the redundant "MY TAG" inline header. Two of the existing portals now share the anatomy; Track Portal (net-new) + Workspace root are the remaining altitudes.
    • ⚠ Field note captured (owner, 2026-06-30): the Project Portal's "Cover approved 1/5" counts cover per-track, contradicting the locked "cover = release/project art" decision (doc-28). Logged in doc-28 §Cover as a reconcile-later (Album/Compilation → project-level cover step; Single → per-track is fine). Not fixed this pass (owner: "consider afterwards").
  • doc-49 slice 1 — the Portal anatomy: PortalZone + Project Portal de-cluttered (iOS) (2026-06-30, Claude Code; xcodebuild BUILD SUCCEEDED). First slice of the Portal redesign — kills the "messy 11-section wall" the owner flagged.
    • New DesignSystem/Components/PortalZone.swift — the doc-49 §3 anatomy primitive: an ordered, optionally-collapsible zone (header = tap-to-toggle row with a rotating chevron + optional glyph/ accessory; smooth expand). Primary zones non-collapsible/expanded; secondary zones collapsed by default — progressive disclosure, calm by default (doc-47 §7). The shared building block that makes "one grammar at every altitude" real in code.
    • Project Portal re-grouped into the anatomy order (ProjectPortalContent): Identity (header + metadata) · Health (release readiness) · Content (tracklist + album art) · Workflow — all visible; then collapsed groups Team & sharing (team + share + team-share) · Notes · My tag (viewer) · Manage/Danger (delete). Eleven stacked sections → ~5 visible + tidy collapsed groups. Stripped the now-redundant "MY TAG" inline header (the zone owns it).
    • Reference implementation for the fractal: next slices roll PortalZone to Artist + Track (Track becomes a Portal) and the Workspace root, and extract the truly-shared zone components.
  • doc-50/42 — work board: inline "advance" (act where the work sits) (iOS) (2026-06-29, Claude Code; xcodebuild BUILD SUCCEEDED). The paradigm-B promise made real — you can now move a project forward without leaving the board. Each card whose blocking step is project-level gets an inline action button — Approve / Mark done / Mark added / Decide / Reviewed (verb per step type) — that PATCHes /project/:id/readiness to the step's satisfied value, then reloads so the project re-buckets to its next stage live. No new backend (reuses the doc-46 readiness verb; owner/admin-gated server-side — graceful "you may not have permission" on 403).
    • Track-level blocking steps stay read-only here (they live per-track, in the Queue/Track surfaces) — only project-level steps are advanceable from the board, via ManagementBoardItem.advanceableStep.
    • Board card restructured into two tap targets (no nested buttons): body → opens the project; trailing → the Advance button (or a chevron when not advanceable). MgmtCtrlViewModel.advance() + advancingProjectId spinner.
  • doc-50/42 — work board: Home→Board deep-link + "what's blocking" per project (iOS) (2026-06-29, Claude Code; xcodebuild BUILD SUCCEEDED ×2). Two refinements that make the action-first flow feel intentional.
    • Cross-segment deep-link — the Home pipeline chip now lands directly on the Manage ▸ Board (not the Overview): AppRouter gained manageMode + goManage(mode:); MgmtCtrlView consumes it (.onChange/.onAppear) to switch its segment, then clears it. So Home strip → tap → the work board is one seamless hop.
    • "What's blocking" on each board card — every project now shows Next: {first unsatisfied step} (accent) — e.g. Next: Mastering sign-off — surfacing what to do to advance it, not just audio/cover counts. ManagementBoardItem.blockingStep walks the (already-present) stage→step rollup; no backend change.
    • Still open: truly inline act-from-the-board (approve/advance without leaving) — limited until a project-level approve verb exists; the per-track Queue covers approvals today.
  • doc-50/42 — Action-first work board: Manage Board grouped by workflow stage (iOS) (2026-06-29, Claude Code; xcodebuild BUILD SUCCEEDED). The richer paradigm-B surface the Home pipeline strip taps into — the Manage ▸ Board went from a flat "release readiness" list to a real work board: projects grouped by their current (first-unsatisfied) workflow stage (stage header + count, in stage order, Ready last). Each project card is now tappable → opens the project (ProjectRoute on the Manage stack's shared browse grammar) so you can act from where the work sits. No backend change (reuses the cascade-aware /management/board + currentStageLabel). Light polish noted: the Home pipeline chip lands on the Manage tab (Overview), not yet deep-linked to the Board segment — cross-segment routing is a follow-up.
  • doc-50/37 — Action-first center of gravity LOCKED + first slice: Home pipeline strip (iOS) (2026-06-29, Claude Code; xcodebuild BUILD SUCCEEDED). Owner chose Action-first (B/C) as the app's primary paradigm: lead with the work + decisions, not the catalog tree. Decision recorded (doc-50 §6) + reconciled — doc-37 Home promoted from sibling to protagonist, doc-47 §10.2 + doc-49 annotated (Portals = the substrate you land on when you act, not the daily entry; backbone kept, not discarded).
    • First build slice — a "PIPELINE" strip on Home (doc-37 protagonist): a horizontal row of stage chips (e.g. Recording 3 · Mixing 2 · Approval 5 · Ready 4) showing where the catalog's work sits by workflow stage, right under the wordmark. Each project counts toward its current (first-unsatisfied) stage; ready projects bucket into "Ready". Taps through to the Manage board (router.go(.manage)).
    • No backend change — reads the existing /management/board (the cascade-aware readiness already carries per-project stages, doc-48); ManagementBoardItem gained stages + a currentStageLabel helper; DashboardViewModel best-effort-fetches the board + aggregates the pipeline. Composed only from DesignSystem.
    • Next slices (action-first build-out): richer work-board view (full stage columns / drill to the projects in a stage) · then the doc-49 Portal redesign as the substrate · ⌘K accelerator (later).
  • doc-50 — UX paradigm options: what's the right center of gravity? (envisioning/decision-support) (2026-06-29, Claude Code; owner-raised — "envision more options to confirm we're on the greatest track, UI+UX, without much detail"). A deliberate step back before investing in the doc-47/49 build. Frames the "center of gravity" lens (every app has one primary paradigm + secondary lenses) and a 6-option menu: A Object/Spatial (current — open into the catalog tree) · B Workflow/Board (open into a stages board, move work forward) · C Activity/Feed (open into what-needs-you, act inline) · D Command/Search (⌘K intent bar) · E Document/Canvas (composable, LEGO-first) · F Player/Media (library+player, craft-first). Evaluation vs north star (MVP = Review & Approve, doc-19; differentiator = workflow, doc-46; B2B calm craft, doc-31): the heart is move-work/decide (B/C) + craft (F), while A is the substrate, not the daily entry — an A-primary app makes a busy manager hunt for what needs them. Recommendation: keep the fractal-Portal backbone (doc-49) but make the primary action-first (B/C) — promote doc-37 Home from sibling to protagonist + a light cross-catalog work-board; portals/tree become the substrate you drop into; ⌘K (D) as accelerator; Canvas/LEGO (E) as the post-PMF ceiling. Invalidates none of the backbone already built — re-frames sequencing only. §6 = the center-of-gravity choice to confirm. Registered roadmap §8; defers to 00-ROADMAP.md. No code this pass.
  • doc-49 — The Portal System: one fractal, clean anatomy at every altitude (envisioning/redesign) (2026-06-29, Claude Code; owner-raised). The owner's two observations — "the Project Portal is messy/ complex, needs a redesign" and "I don't see portals at Workspace/Roster, Artist, Track, Configure" — are one root cause: there is no Portal system. Verified audit: Portal.swift is a bare sheet+VStack; ProjectPortalContent flat-stacks ~11 heterogeneous sections (no hierarchy/disclosure → the mess); Artist/Project hand-duplicate team/share/notes/my-tag/delete; Track/Workspace/Roster have no Portal → the doc-47 §6 fractal principle ("one grammar at every altitude") was never built. The fix: one Portal anatomy = 8 ordered zones (Identity · Health · Content · Workflow/ Config · Relationships · Notes · Personal · Manage/Danger) with progressive disclosure (summary→expand, calm by default); only the Content zone varies by altitude → a true fractal, not 8 screens. Owner decisions: (1) envision first (this doc); (2) full ladder incl. Workspace rootWorkspace ("cloud root space") → Artist → Project → Track → File. Clarified Roster = tag/lens (no own portal, a grouping inside the Workspace Portal + a Browse lens); Workspace = tenant root gets the root portal. Structural fix = a shared PortalScaffold + zone components (kills the per-entity duplication = doc-47 §6 in code). Build: scaffold → redesign Project Portal (reference, fixes the mess) first → roll to Artist+Track (Track becomes a Portal) → Workspace root → File. UI-layer over mostly-existing endpoints (net-new backend small = doc-45 asset cloud, deferred). §7 forks open (sheet-vs-fullscreen · Workspace portal home · Track-portal-vs-player · disclosure aggressiveness). Registered roadmap §8; defers to 00-ROADMAP.md. No code this pass.
  • doc-48 — studio default workflow: the iOS surface (set it + see inherited-vs-custom) (2026-06-29, Claude Code; nest build + xcodebuild BUILD SUCCEEDED). Makes the cascade usable + visible — you can now pin a studio default and see, on each project, whether its process is inherited or custom.
    • Configure ▸ Workflow — the list shows a STUDIO DEFAULT badge (and a SYSTEM badge on the shipped default); the editor gained a star control "Set as studio default" / "Studio default — every project inherits this" (tap to clear). Works for any template incl. the read-only system one. VM loads GET /workspace and writes PATCH /workspace/default-workflow.
    • Project Portal — the workflow section now shows the source (Custom for this project · Inherited · studio default · Default process) and a "Change" menu with Apply a template plus, when custom, "Reset to studio default"DELETE /project/:id/workflow (clears the local instance → cascade governs again).
    • Small backend support added: release-readiness now returns workflowSource (project/studio /system) via a new ProjectService.resolveWorkflowMeta; new DELETE /project/:id/workflow (resetWorkflow, owner/admin). resolveWorkflowStructure now delegates to …Meta (mgmt unchanged). nest build clean.
    • New Core/Models/WorkspaceSettings.swift; ReleaseReadiness gained workflowSource. The doc-48 studio-default loop is now end-to-end on iOS. Remaining: Artist override (rung 2) · attributes (doc-43) · web parity.
  • doc-48 — studio-wide workflow cascade: backend (the workspace/owner default) (2026-06-29, Claude Code; nest build + jest 75/75 + workflow e2e 5/5 green). The owner's #1 from doc-48 — "my studio works this way" — now resolves automatically: a project with no own workflow inherits the studio default with zero setup.
    • New workspace module (the forward-compatible seed of the doc-44 Workspace container) — Workspace doc keyed 1:1 by owner with defaultWorkflowTemplateId. GET /workspace (my settings) · PATCH /workspace/default-workflow {templateId} (set/clear; validates the template resolves for the owner). JWT-gated.
    • The cascadeProjectService.resolveWorkflowStructure(project) walks project.workflow ▸ owner's studio default ▸ system default (a since-deleted default falls through). release-readiness resolves it and passes it to computeReleaseReadiness (which gained an optional structureOverride and stays this-free, so the unit specs are untouched). Same most-specific-wins shape as doc-44 perms / doc-41 status.
    • e2e (in-memory Mongo) — set a studio default → a brand-new project's readiness resolves to that structure (not the system one); clearing the default falls back. +1 test (now 5/5).
    • mgmt.ctrl now reflects the cascade tooManagementService.computeBoard + buildMatrix resolve each project's structure via resolveWorkflowStructure (project ▸ studio default ▸ system) before the readiness rollup, so the Board / Matrix / Overview "release-ready" counts honour the studio default instead of always falling to system. jest 75/75 (spec mock gained resolveWorkflowStructure).
    • Not yet: the iOS surfacing — "Set as studio default" in Configure ▸ Workflow + "Inherited vs Custom / Reset" on the Project Portal (next slice) · Artist-level override (rung 2) · attributes on the same rails (doc-43). (Per-project resolve in the eagle-view loop is N small indexed look-ups — batch by owner if it ever shows up in profiling.)
  • doc-48 — Configuration across the hierarchy (consolidation/envisioning) (2026-06-29, Claude Code; owner-raised). Unifies the owner's two open uncertainties — workflow across Roster→Variant and LEGO attributes across the hierarchy — into one model: define-at · apply-at · resolve (cascade + override). Verified finding: authoring is workspace-scoped (templates) but application is Project-only today — no cascade; every project must be instantiated individually. Real container ladder = Workspace → ArtistSpace → Project → Track; Roster = a tag/lens and Variant = a predicate (neither is a node). Resolve walks most-specific-wins up the ladder — the same rule as doc-44 permissions and doc-41 status. Owner decisions: (1) envision first (this doc), then build; (2) priority = the studio-wide workflow cascaderesolveWorkflow(project) = project.workflow ?? artist default ?? workspace default ?? system, so a new project inherits the studio process with zero setup; "set as studio default" verb + Portal "inherited vs custom / reset". Attributes (doc-43) ride the same rails; surfacing splits Configure (define) / Browse + Manage (apply + view) — the Configure "Soon" bubbles map here. §8 forks open (where the default lives — Workspace vs Artist · cascade depth · override granularity · build the Workspace container now vs interim-store). Registered roadmap §8; defers to 00-ROADMAP.md. No code this pass.
  • doc-46 — workflow editor: native drag-reorder + approver-role authoring (iOS) (2026-06-29, Claude Code; xcodebuild BUILD SUCCEEDED). Completes the two authoring gaps the owner flagged.
    • Editor rebuilt on List — steps now drag-reorder via the native gesture (toolbar Edit), swipe-to-delete, and the title/duplicate stay above the list. Stage Sections keep their rename/move/delete menu; cross-stage move + per-step delete stay in the step "⋯" menu. Read-only default uses .moveDisabled/.deleteDisabled.
    • Approver role per step — each step carries an approver/owner role (Owner/Admin/Editor/Viewer · doc-44, or Anyone), set from an inline accent chip-menu and in the Add-step sheet. Persists in the template's approverRole (already on the wire model) and surfaces as the step's ownerRole in the readiness rollup → the Project-Portal pipeline already shows it.
    • Not yet: per-variant appliesToVariants · config across other hierarchy dimensions (workflow anchors at Project only today) · the LEGO attribute engine (doc-43) · web parity (parked).
  • doc-46 — workflow editor depth: stage CRUD + reordering (iOS) (2026-06-29, Claude Code; xcodebuildBUILD SUCCEEDED). The template editor is now a full process-builder, not just a required-toggle.
    • Steps reorder & move — each step's "⋯" menu gained Move up / Move down (within its stage, edge-disabled) and Move to stage → (a submenu listing the other stages) alongside Delete. Order is preserved on save (PATCH replaces stages).
    • Stages are now authorable — each stage header has a "⋯" menu (Rename via alert · Move up/down · Delete), and a bottom "Add stage" button creates a new (empty) stage to fill. Collision-free stage_… keys generated client-side (mirrors the step key-gen).
    • Model helper WorkflowTemplate.uniqueStageKey(for:) + refactored shared key-gen. The whole structure (stage order · step order · membership · labels · required) round-trips through the existing PATCH /workflow/templates/:id. Composed only from DesignSystem.
    • Not yet: true drag-gesture reorder (menu move up/down is the mechanism for now) · approverRole/ assignee authoring · per-variant appliesToVariants · web parity.
  • doc-46/47 — workflow authoring UX polish + Home field-feedback captured (iOS) (2026-06-28, Claude Code; xcodebuild BUILD SUCCEEDED). Owner field feedback ("can't apply / can't rename") was a discoverability gap — a new user only has the read-only default template, and the rename path (duplicate → edit) wasn't obvious. Fixed:
    • Duplicate-to-customize is now prominent — the read-only default's editor shows a primary "Duplicate to customize" button (not a buried toolbar item), and duplicating now pushes straight into the editable copy (onDuplicatedpath.append) so you land ready to rename + tailor, instead of popping back to the list. Editor copy clarified ("Apply it to a project from that project's portal").
    • Apply control clarified — the Project-Portal affordance relabelled "Apply a template" (flowchart glyph) so the instantiate action reads as an action.
    • Captured (deferred, owner) — Home / front-door rethink: leading with pending-approvals + open-notes isn't always the right landing when actively working inside an artist/project (owner had to switch tabs); Home may need to be context-aware / remember last zone, and Home scroll behaviour needs a look. Logged in doc-47 §11 "Field feedback" to design before building (informs a future doc-37 revision) — not built this pass.
  • doc-46 — instantiate-a-template on the project: the author→apply loop closed (iOS) (2026-06-28, Claude Code; xcodebuild BUILD SUCCEEDED). The missing link between authoring a workflow (Configure ▸ Workflow) and running it (the Project-Portal pipeline): owner/admins can now apply a template to a project from the portal itself.
    • WorkflowPipelineSection gained a "Use template" menu (owner/admin) listing the available templates (default + the user's own); selecting one calls POST /project/:id/workflow/instantiate with {templateId}, which resolves the template's structure server-side into project.workflow.stages, then the portal re-fetches release-readiness so the pipeline + readiness pill recompute over the new structure. Per-step statuses keyed by a surviving step are preserved.
    • ProjectPortalContent best-effort-fetches /workflow/templates alongside readiness (menu just doesn't appear if it fails) + commitInstantiate. New InstantiateWorkflowRequest wire type.
    • End-to-end now: author in Configure → apply on the project → the doc-46 pipeline renders & rolls up that exact structure. Not yet: template drag-reorder / stage add-remove · per-variant appliesToVariants (doc-28 variantType exists) · web parity.
  • doc-46/47 — Workflow template authoring: the first real Configure citizen (iOS) (2026-06-28, Claude Code; xcodebuild BUILD SUCCEEDED). The Configure zone now does something — its top row flips from Soon to live. Studios can author the doc-46 workflow they instantiate on projects, entirely from the app, over the existing /workflow/templates CRUD (no new endpoints).
    • List (Features/Configure/WorkflowTemplatesView.swift) — the read-only Studio Release (default) and the user's own templates, each showing stages · steps · required counts; creates a new template seeded from the default's structure (the recommended starting point) and opens its editor.
    • Editor (WorkflowTemplateEditorView.swift) — view any template; for a custom one: rename, toggle each step's required, edit a step's label, add a custom step to a stage (name + type picker over approval/task/asset/decision/review with plain-language hints, plus a project-vs-track level), delete a step, Save (PATCH replaces stages), or delete the template. The system default is read-only with a Duplicate to customize action. Unique custom step keys generated client-side (custom_…, collision-free).
    • Models Core/Models/WorkflowTemplate.swift (WorkflowTemplate/Stage/Step + request body + key-gen helpers); ConfigureItem.workflow now isReady → routes to the list. Composed only from DesignSystem.
    • Not yet: stage add/remove + drag-reorder of steps/stages (stages come seeded from the default this slice) · approverRole/assignee authoring · instantiate-a-template picker on the project (the POST /project/:id/workflow/instantiate consumer — pairs with the doc-46 pipeline already on the Project Portal).
  • doc-47 slice 1b — declutter Home + cross-tab routing (iOS) (2026-06-28, Claude Code; xcodebuildBUILD SUCCEEDED). Makes the new tab shell feel intentional rather than layered-on.
    • Home is now just the front door. Removed the quick-access tile row (Browse/Manage/Configure are tabs now) — DashboardView stays focused on "what needs me" (approvals · notes · activity · recents), not a launcher. The avatar drawer trimmed to the account corner + overflow: dropped the redundant Workspace group (mgmt.ctrl/artists/projects/tracks/teams — all zones now), keeping only Library (Shared · Recents, which have no tab home) + the You section (appearance · sign out).
    • AppRouter (new, in RootTabsView) — a @MainActor @Observable cross-tab coordinator owning the selected zone + the Browse path, injected into the environment. Lets a surface in one tab switch tabs and deep-link in one gesture. First use: create-artist (Home toolbar/empty-hero) now router.openArtistInBrowse(id:) → hops to the Browse tab and opens the new artist there, instead of pushing a redundant copy inside Home's stack. BrowseZoneView roots its NavigationStack on router.browsePath.
    • Not yet: more cross-tab entry points as they arise · the first real Configure citizen (workflow template authoring, doc-46) · Roster Portal · account "own corner" / Contact ID.
  • doc-47 slice 1 — the three-zone tab shell (iOS) (2026-06-28, Claude Code; xcodebuildBUILD SUCCEEDED). The north-star made navigable: the signed-in root is now a tab bar (doc-47 §10.1) — Home (the front-door "what needs me" sibling, doc-37) · Browse (catalog) · Manage (mgmt.ctrl x-ray, doc-42) · Configure (the control room, formerly with no surface at all). Purely additiveDashboardView (Home) left fully intact, so nothing regressed; the zones appear alongside it.
    • RootTabsView rewritten as a 4-tab TabView (AppZone enum) — reversing its old "deliberately NOT a tab bar" stance per the owner's doc-47 decision. Each zone owns its own NavigationStack + path + zoom namespace.
    • New Features/Shell/BrowseDestinations.swift — the shared .browseDestinations(path:zoomNS:) modifier installs the four route destinations (String/track → artist → project → track, doc-36 zoom transitions) once, so Browse and Manage drill the hierarchy identically ("one grammar at every altitude", doc-47 §6) instead of per-stack copies. Browse roots ArtistsHomeView; Manage roots MgmtCtrlView.
    • New Features/Configure/ConfigureView.swift — the Configure zone as an honest thin shell (doc-47 §11): lists the control-room citizens (Workflow templates · Teams & roles · Attributes · Workspace) with what each is; Teams routes to the real TeamsView, the rest carry a Soon pill (their authoring UIs are named later slices). Composed only from DesignSystem.
    • Not yet (named follow-ups): declutter Home's now-redundant quick-tiles/drawer groups (Browse/Manage are tabs now) · cross-tab deep-linking (tap a Home shortcut → switch tab vs push-in-place) · the first real Configure citizen = workflow template authoring (doc-46's next step) · Roster Portal · account "own corner" / Contact ID.
  • doc-47 — Product North Star: three zones + one navigation grammar (consolidation/envisioning) (2026-06-28, Claude Code; owner-raised). A step back to consolidate the whole product so surfaces stop being bolted on. Diagnosis: today = object-first Portals (bottom-up) + a bolted-on Manage tab (top-down), with no orientation/intent layer and no home for the configuration substrate (workflow/attributes/teams). 2 decisions locked with owner: (1) navigation model = three zones + fractal PortalsBrowse (catalog; one Portal grammar at every altitude — roster→artist→project→ track→file, roster finally gets its Portal) · Manage (top-down x-ray = doc-42 + doc-38) · Configure (the missing control room = doc-46 templates · doc-43 attributes · doc-44 teams/roles · account); (2) capture as a north-star doc first, then build. The front door ("what needs me", doc-37) = role-driven landing over the zones, not a 4th zone. Doc invents nothing — places every prior doc (reconciliation map §8) and adds the actors×actions matrix (§4) + 6 design principles (intent-first · one grammar at every altitude · calm→dense · configuration-as-a-place · one-surface-role-driven · minimum-taps). The real gap surfaced: Configure has no surface today. §10 forks open (zone-switcher mechanism · front-door↔Manage merge · workspace container · MVP subset). Registered in roadmap §8; defers to 00-ROADMAP.md on conflict. No code this pass.
  • doc-46 — workflow engine iOS surface: the release pipeline in the Project Portal (2026-06-28, Claude Code; xcodebuild BUILD SUCCEEDED). First time the workflow engine is visible to users — the deep backend (stages → steps, per-type satisfaction, configurable structure) now renders as a pipeline on the Project Portal, driven by the same GET /project/:id/release-readiness rollup the portal already fetched. No new endpoints.
    • New Features/Browse/WorkflowPipelineSection.swift — ordered stage cards (preproduction → recording → production → rights → release) each with a satisfied progress bar and its steps; track-level steps show an approved count (x/total), project-level / custom steps show a per-type state chip. Composed only from DesignSystem tokens (SnSpacing/SnRadius/Color.sn*).
    • Inline step authoring — owner/admins set a project-level step's status from a per-type menu (approval/task/asset/decision/review each carry their own state vocabulary, mirrored from workflow.types.ts STEP_STATES/STEP_SATISFIED) → PATCH /project/:id/readiness, then the rollup re-fetches so counts + the readiness pill recompute. tracklist is read-only here (it owns the dedicated Tracklist section; the endpoint rejects it).
    • New Core/Models/WorkflowModels.swift (WorkflowStepType with states/satisfied/glyph, WorkflowReadinessStage/WorkflowReadinessStep, UpdateProjectReadinessItemRequest) + ReleaseReadiness extended with optional stages/items (back-compat). Wired into ProjectPortalContent below the release-gate headline.
    • Not yet: workflow template/instance authoring on iOS (instantiate from a template, add/rename/ remove steps — backend POST :id/workflow/instantiate + PATCH :id/workflow exist) · per-variant appliesToVariants (doc-28 variantType now exists) · web parity.
  • doc-28 Option C — recording variants + cross-references + multi-album (backend) (2026-06-28, Claude Code; nest build + jest 75/75 + e2e 3/3 green, lint clean). The pragmatic middle the doc locked: models remixes / radio-edits / explicit-clean / live + "a track on multiple albums" without a full Release/Work remodel. Backend-contract-first (iOS chips/lenses follow). Also unblocks doc-46's per-variant (now there's a variantType to evaluate appliesToVariants against).
    • Track schema (doc-28 Option C): variantType enum (original default · radioEdit · explicit · clean · instrumental · remix · live) · relations: [{track, type}] (alternateOf · remixOf · sameRecordingAs) · appearsInProjects: [projectId] (SECONDARY membership; the canonical project stays home). New models/variant.type.ts.
    • Mirror-write logic (TrackService) — addRelation/removeRelation write both tracks so a cross-link is navigable forward AND back (symmetric types same; remixOf mirrors the same type for now — inverse "remixedBy" label is a noted refinement). addAppearance/removeAppearance (guards the home project) · getRelations resolves variant + cross-links + appearances for the chips. Writes gated to owner/admin/editor (lighter metadata than approvals).
    • Endpoints: GET/POST/DELETE /track/:id/relations · POST/DELETE /track/:id/appearances · variantType settable via the existing PUT /track/:id (schema enum-validated). e2e (in-memory Mongo): default original, mirror cross-link both ways + remove, secondary appearance + home-guard.
    • Not yet: Option B (formal Release/UPC, true M2M) · the iOS variant chips + group-by/cross-link lenses (doc-26) · wiring per-variant appliesToVariants into the workflow rollup (doc-46, still envision-first).
  • doc-46 — workflow engine LIVE smoke-test + template CRUD completion (backend) (2026-06-28, Claude Code; nest build + jest 75/75 + e2e 4/4 green, lint clean). De-risked the foundation by running it for real, then completed template management.
    • Integration smoke-test (test/workflow.e2e-spec.ts, on mongodb-memory-server) — boots the real Mongoose models against an in-memory MongoDB and round-trips the whole flow: list system default → create custom template → instantiate on a project (the embedded Project.workflow Object actually round-trips through Mongo) → custom task step satisfied on 'done'computeReleaseReadiness ready; plus a project-level step status + assignee persisted. Validates DI + Mongoose storage + the rollup over real documents — what the pure unit tests can't. Added the ^src/(.*)$ path mapper to test/jest-e2e.json. First time the workflow engine has executed end-to-end.
    • Template CRUD completedGET /workflow/templates/:id · PATCH /workflow/templates/:id (edit name/stages) · DELETE /workflow/templates/:id, all owner-scoped; the computed system default is immutable (edit/delete → 400). +1 e2e test (edit/delete + system-immutability).
    • Per-variant NOT built (correctly blocked)Track has no variantType yet (doc-28 unbuilt), so appliesToVariants would be inert; and the owner chose envision-first for variants. The model is captured in 46 §per-variant (per-project workflow · step-scope: recording-intrinsic vs release-contextual). Builds when doc-28's variantType ships.
  • doc-46 §per-variant & multi-album workflow — envisioning (2026-06-28, Claude Code; owner-decided). Captured (not built) the per-variant model before coding. Owner decision: workflow is PER-PROJECT — a recording can sit at a different stage in each album it appears in. Resolves the doc-28 tangle (variantType + appearsInProjects[]) via step SCOPE: recording-intrinsic steps (takes · arrangement · mix · master) follow the recording (shared across albums); release-contextual steps (cover · title · tracklist · details) are per (recording, project). A variant = a Track; per-variant = per-track appliesToVariants. Status-storage seam noted: multi-album needs a (recording, project) junction for release-scoped steps; today's Track/Project.readiness maps are the seam. Recorded in 46-WORKFLOW-ENGINE.md §per-variant.
  • doc-46 — per-type step states + per-step assignment (backend) (2026-06-28, Claude Code; nest build with jest 75/75 green, lint clean). Steps now behave correctly by type, and each step can be assigned to a person (actor-awareness — the "ensure it adapts to the user" thread).
    • Per-type satisfaction (workflow.types.ts): each step type has its own state vocabulary + one "satisfied" value — approval=Approved · task=done · asset=present · decision=decided · review=reviewed (STEP_SATISFIED · STEP_STATES · satisfiedFor()). The release gate uses satisfiedFor(step.type, status), so a task is done on 'done', not 'Approved' (and vice-versa).
    • computeReleaseReadiness is type-aware — per-step satisfied via satisfiedFor; each rollup item now carries ownerRole (from the structure) and, for project-level steps, assignee. Track step status relaxed to a free string (per-type vocab) — title/version/cover stay on /release-status.
    • Assignment: PATCH /track/:id/readiness and PATCH /project/:id/readiness accept an optional assignee (userId), stored alongside the step status in the readiness maps.
    • +3 tests (per-type satisfiedFor; a task step rejecting an approval-style status; embedded custom task step satisfied on 'done'). Still later (doc-46): per-variant overrides (align with doc-28 — variantType + a track appearing in multiple albums) · template edit/delete · the authoring UI · iOS surfaces.
  • doc-46 increments 2–3 — workflow templates + per-project instances + custom steps (backend) (2026-06-28, Claude Code; nest build + jest 73/73 green, lint clean). The differentiating foundation: studios define their process once, projects instantiate + tweak it, steps can be fully custom (in the studio's own language). Structure is configurable; status storage is unchanged (no migration) — the clean seam. Also: relabeled the over-generic metadata step → "Details" (owner's call; avoided "Attributes" to not collide with doc-43's term).
    • src/shared/workflow.types.tsWorkflowStructure (Stages → Steps) shared by templates + instances; buildDefaultWorkflowStructure() (the "Studio Release" default, derived from the catalog), applyProfile() (back-compat with the doc-45 flat profile), requiredSteps().
    • New workflow moduleWorkflowTemplate entity (owner-scoped, stages[]) + WorkflowService (GET /workflow/templates = computed system default + the user's templates; POST to create) + resolveStructure(templateId).
    • Project.workflow (embedded instance) + Project.readiness map (project-level/custom step status). POST /project/:id/workflow/instantiate (copy a template/inline structure in), PATCH /project/:id/workflow (replace structure — add/remove/rename/custom steps), GET /project/:id/workflow (instance or default), PATCH /project/:id/readiness (project/custom step).
    • computeReleaseReadiness is now structure-driven — rolls up the project's embedded workflow if present, else the default + readinessProfile; resolves each step's status from the existing stores by key (legacy releaseStatus for title/version/cover · Track/Project.readiness maps for everything else, incl. custom keys). Keeps items[] · stages[] · all legacy fields → mgmt.ctrl Board unchanged. Custom track steps settable via the relaxed PATCH /track/:id/readiness (any non-legacy key, validated service-side, not the static catalog).
    • +5 tests (workflow.types helpers + an embedded-workflow rollup with a fully custom step "beatSold"). Design principle in code/doc: "start simple, scale to your complexity — only when you need it." Still later (doc-46): per-type states · per-variant overrides · per-step assignment · the authoring UI · iOS surfaces.
  • doc-46 increment 1 — Stages → Steps + default "Studio Release" template (backend) (2026-06-28, Claude Code; nest build + jest 68/68 green, lint clean). First build of the workflow engine on the shipped readiness seed — evolves the flat checklist into the lifecycle pipeline shape. No new schema or endpoints; catalog + rollup only. Motto threaded in: "start simple, scale to your complexity — only when you need it."
    • readiness-catalog.ts → the default workflow template — items now carry a stage (Pre-production · Recording · Mix & Master · Rights & Credits · Release, ordered via WorkflowStages) and a type (approval/task/asset/decision/review). Added creative steps (studio / arrangement / takes, optional) so the lifecycle is represented, and relabeled metadata → "Credits & metadata" (the over-generic name the owner flagged). Existing keys preserved → DefaultReadinessProfile unchanged in content (back-compat).
    • computeReleaseReadiness now returns stages[] — the required steps grouped into lifecycle stages (each: label · steps · satisfiedCount · satisfied), in stage order; only stages with a required step appear. Keeps items[] + all legacy fields, so mgmt.ctrl Board + the project endpoint are unchanged. The new creative track steps are settable via the existing PATCH /track/:id/readiness (auto-included in TrackReadinessKeys).
    • +1 unit test (stage grouping); existing readiness tests updated for stage-ordered output.
    • v1 simplification (noted): type is metadata for clients; satisfaction is uniform (Approved). Per-type states · workspace templates + instantiate/tweak · per-variant · assignment · authoring UI are doc-46's later increments.
  • 46-WORKFLOW-ENGINE.md — adaptable, actor-aware workflow engine envisioning (2026-06-28, Claude Code; owner-raised off doc-45). Owner pushback: a fixed Title·Cover·Version·…·Metadata checklist is too release-centric and technical, ignores the creative/recording lifecycle (re-record · mic/studio/featured-artist · test-pressing · splits · contracts) where most actors + work live, and is only toggle-configurable. Reframe: "release readiness" → "model your own process." Generalizes the shipped doc-45/32 readiness seed (a flat profile) into Stages → Steps. 3 decisions locked (2026-06-28, owner): (1) shape = ordered Stages → Steps (lifecycle pipeline; not flat list, not state machine); (2) templates + full custom steps (opinionated workflow templates as starting points AND owner-defined custom steps in their own language); (3) studio/workspace templates → project instance (+ per-variant doc-28 overrides). Step = type (approval/task/asset/decision/review) + ownerRole/assignee + checkpoint + assetSlot; release gate = "all required steps satisfied". Hub = mgmt.ctrl Board + Portals · config engine = doc-43 LEGO · actors = doc-12/17 · gating = doc-44 · assets = doc-45. User-friendly = template-first, calm-by-default, their-language. Default "Studio Release" template grounds the real lifecycle (Writing→Pre-pro→Recording→Mix→Master→QC→Rights→Release). Post-MVP, built incrementally on the shipped seed. Registered in 00-ROADMAP.md §8.
  • doc-45/32 — configurable release-readiness profiles (LEGO), backend (2026-06-28, Claude Code; nest build + jest 67/67 green, lint clean). First build off the doc-45 envisioning — the readiness checklist is now data, not a hardcoded Title·Cover·Version triple: a project chooses which items it requires and the release gate rolls up that set. Minimal LEGO (doc-43 pattern) applied to the workflow.
    • src/shared/readiness-catalog.ts — the available readiness items as fixed presets (stable key + i18n label, like lifecycle-status.ts): title · version · cover · lyrics · master · metadata (track) · tracklist (project), each with level + approverRole + defaultRequired. Default profile = title/version/cover/tracklist (today's behavior).
    • Project.readinessProfile?: string[] (the configurable required set; absent ⇒ default) + Track.readiness map (generic store for the non-legacy items; title/version/cover keep living on releaseStatus for back-compat).
    • computeReleaseReadiness generalized — rolls up over the project's profile (track item ⇒ every track Approved; project item ⇒ project Approved); returns the per-item items[] + profile plus all legacy fields (audioApproved/coverApproved/tracklistApproved/ready) so mgmt.ctrl Board + the project endpoint are unchanged.
    • Endpoints: GET /project/readiness/catalog (render labels + customize) · PATCH /project/:id/readiness-profile (set required set, owner/admin-gated, keys validated) · PATCH /track/:id/readiness {key,status} (set lyrics/master/metadata, gate mirrors release-status) · GET /project/:id/release-readiness now returns the configurable rollup. 7 unit tests on the rollup (default + custom profiles, readiness-map items, back-compat fields).
    • Next: the iOS surface (render the live checklist + edit a project's profile + set the new item statuses) on the mgmt.ctrl Board / project Portal; later, per-variant profiles (doc-28) + full LEGO config authoring (doc-43).
  • 45-RELEASE-PIPELINE-ASSETS.md — release pipeline / project assets envisioning (post-MVP) (2026-06-28, Claude Code; owner-raised mid-mgmt.ctrl). Captures a product idea: motion/animated covers (Apple-Music-style) stored · previewed-as-the-store-renders · approved, inside a project "cloud"/asset space (lift attachments from Track up to Project/Artist + documents), plus the realization that release-readiness has more & configurable elements than Title·Cover·Version — varying per project/album and per variant (remix/radio-edit/live/instrumental). Framed as largely a synthesis of doc-32 (approval dimensions) + doc-28 (variants) + doc-43 (LEGO — applied to the readiness checklist itself) + doc-34 (images → motion media) + track attachments; net-new = motion- cover media type + per-node asset space + configurable readiness profiles. Vision: release-readiness = a configurable, variant-aware checklist over a project asset space, surfaced by the mgmt.ctrl Board and Portals (the workflow hub). Full configurability depends on doc-43; motion covers + asset space are separable and could land earlier. Open questions (format/preview fidelity · asset-space model · profile authoring · variant↔profile binding) parked for a deeper post-MVP pass. Registered in 00-ROADMAP.md §8.
  • doc-42 mgmt.ctrl — iOS Matrix view (catalog health tree) (2026-06-27, Claude Code; xcodebuildBUILD SUCCEEDED, first try). Adds the 5th view to the iOS command center, after the owner confirmed the MVP works live. A phone-native roster → artist → project → track tree off GET /management/matrix: roster headers · artist cards with lifecycle pills · per-project DisclosureGroup (header = project + audio/cover readiness counts + a READY/BLOCKED badge; expands to track rows with a release-status pill); tapping a track navigates to its player (peek-then-go). New ManagementMatrix* Codable models; MgmtCtrlViewModel now loads the matrix as a 5th parallel read. Composed from DesignSystem (LifecycleStatusPill · StatusPill · SectionHeader · .snCard). Remaining mgmt.ctrl view: Insights heatmap UI (doc-38 read side). Noted backlog (doc-42 §13 follow-ups): richer activity audit (create/update/delete · who/what/when) + doc-40 filter/search/order-by.
  • doc-42 mgmt.ctrl — iOS MVP screen (Overview · Timeline · Queue · Board + bulk) (2026-06-27, Claude Code; xcodebuild BUILD SUCCEEDED, iOS Simulator SDK). The first built mgmt.ctrl surface, on iOS (web deferred to post-iOS-MVP per owner). Requirements locked in 42-MANAGEMENT-CTRL.md §13.
    • New Features/Management/MgmtCtrlView (a ScreenBackground + Overview KPI grid + a Timeline · Queue · Board segmented switcher, calm cards composed only from DesignSystem) + MgmtCtrlViewModel (@Observable, loads the four /management/* reads in parallel via APIClient, LoadState, pull-to-refresh) + Core/Models/ManagementModels.swift (Codable mirrors of the backend shapes incl. the provenance breadcrumb → "Pop › Ava › LP1 › Tidewater").
    • Actions: per-row Approve / Request-changes on the Queue (loops PATCH /track/:id/release-status over the under-review dimensions) + multi-select bulk (a bottom action bar → PATCH /management/bulk: bulk approve audio / set lifecycle status), with the partial-failure summary surfaced ("3 done · 1 skipped · 0 failed"). Rows navigate to their source track (peek-then-go, doc-36).
    • Entry: a new AppSection.mgmtCtrl ("management.ctrl"), reachable from both the avatar drawer (Workspace group) and a leading "Manage" tile on the Home quick-access row. No role gate (corrected 2026-06-27, owner): access roles are per-resource (you're owner of a track), there is no global "manager" identity role, and a fresh user owns their own stuff — so mgmt.ctrl shows for everyone as the lens over their own catalog. A real "manager license" (capability granted to a user, independent of any resource) is a separate future axis — recorded in 44-... §open-Q6. The earlier userRole-scalar gate was the wrong abstraction (and the reason it wasn't appearing).
    • Backend enabler: PermissionService gained a grant-or-legacy fallback (grant first, else the resource's owner/collaborators) so the bulk per-node can() is correct for all resources (incl. ones created after a backfill) — bulk works without flipping global PERMISSION_ENFORCE, removing the new-resource 403 hazard. nest build + jest 40/40 green (+2 fallback tests).
    • Verified: full xcodebuild compile green (caught + fixed ShapeStyle vs Color shorthand errors — .snForegroundSecondary/.snStatusAmber need the explicit Color. form in .foregroundStyle). Synchronized folders → new files auto-join the target. Deferred to a later iOS slice: the Matrix grid + Insights heatmap UI (the read side of the telemetry now being captured); web emitter + screens are post-iOS-MVP.
  • doc-38 — iOS telemetry emitter (the capture client) (2026-06-27, Claude Code; xcodebuild BUILD SUCCEEDED, iOS Simulator SDK). Makes the just-built backend backbone real — the iOS app now emits the events the heatmap/Workspace-Insights aggregate. First iOS slice of the telemetry work.
    • Core/AnalyticsClient.swift — a @MainActor batching, fire-and-forget emitter: events buffer and flush to POST /analytics/events on a 20s timer, on app-background, and at a 100-event cap; best-effort (drops on auth-failure, re-queues capped on transport error). Owns the playback-segment bookkeeping so the player makes only one-line calls. Heartbeats carry the listened segment {posStart, posEnd} (→ the waveform heatmap); cumulative msPlayed is emitted once per play session (on complete/stop) so the backend's engagement-band + listen-through stay correct. Resume-after-pause continues the same session; seeks close the played segment so skipped spans aren't heat. Wire types are nonisolated struct … Encodable/Decodable, Sendable (matches the house Codable-model pattern under the project's default-MainActor isolation).
    • Session lifecycle (SonnanceApp.swift) — @Environment(\.scenePhase)app:open on each foreground, session:end (minutes-in-app) + flush on background.
    • Playback hooks (PlayerView.swift, 5 surgical call sites) — play:start/play:pause in playPause(), play:seek in seek(), play:heartbeat from the existing 0.1s time observer (throttled to ~10s segments, only while playing), play:complete in the end-of-track notification, and playbackStop() (session summary) on the player's .onDisappear.
    • Verified: full xcodebuild compile green (caught + fixed a real Swift-6 main-actor-isolation error on the wire types before it shipped). The project uses synchronized folders, so the new file auto-joins the target — no manual Xcode step. Still follow-on: web emitter, guest/link ingest path, identified opt-in role-gating, and the iOS read surfaces (track-Insights heatmap UI, Workspace Insights screen) that render what's now being captured.
  • doc-38 — analytics telemetry backbone (backend, full stack) (2026-06-27, Claude Code; nest build green + jest 59/59 green, lint clean). Owner chose telemetry-first (before iOS). Built the complete doc-38 backend: capture + aggregate-on-read for both consumers. New src/modules/analytics/. Caveat held in the open: the store stays empty until a client emits events — the iOS/web heartbeat emitters are the follow-on work; this is the pipe + the reads.
    • Append-only Event store ({actorType user|guest, actor, linkId, eventType, nodeType, nodeId, sessionId, ts, props, engagementBand, location}) with hot-path indexes + a TTL index on ts (ANALYTICS_RAW_TTL_DAYS, default 180 — raw reaped, aggregate-on-read bounded to the window until materialized rollups land, doc-38 §scale-lever).
    • POST /analytics/events — batched ingest; stamps actor + server-time fallback; classifies each play by depth (engagementBand: impression/sample/listen/complete from msPlayed ÷ File.duration, doc-38 decision 2) via one batched duration lookup.
    • GET /analytics/track/:id/insights (the hero) — waveform heatmap (per-second position histogram over the listened [posStart,posEnd) segments) + plays · unique listeners · avg listen-through % · band breakdown · hook rate (listen/impression).
    • GET /analytics/workspace — owner BI: opens · minutes-in-app · active days · top tracks (scoped to the caller's catalog), windowed (windowDays, default 30).
    • Wired the doc-42 mgmt.ctrl Insights viewGET /management/insights (consumes Workspace Insights) — completes the mgmt.ctrl surface (Activity · Approvals · Overview · Matrix · Board · Insights + bulk). Privacy posture per doc-38: aggregate-by-default, guest = link-attributed with coarse location (city/country, never raw IP), raw-event TTL. Heavy reductions are pure functions (computeTrackInsights / computeWorkspace / classifyEngagementBand) → 7 unit tests, no DB.
    • Still client-side (follow-on, not built): the event emitters (iOS play:heartbeat + session events; web later), the guest/link ingest path (unauthenticated, link-scoped — ties to doc-26/39), and identified opt-in role-gating on reads (aggregate-default holds for now).
  • doc-42 mgmt.ctrl — backend read surface complete (Overview · Matrix · Board) (2026-06-27, Claude Code; nest build green + jest 52/52 green, lint clean). Finishes the cross-catalog read endpoints (the only remaining view, Insights, is correctly deferred — it needs the doc-38 telemetry backbone). All safe, query-scoped, no authz dependency; pure composition over existing services.
    • GET /management/overview — portfolio KPIs: counts of artists · projects · tracks, plus pendingApprovals · releaseReadyProjects · unresolvedNotes (composes ArtistSpaceService.findAll, ProjectService.findAll, the track lists, CommentService.getUserRollup).
    • GET /management/matrix — the Catalog Health Matrix: roster → artist → project → track tree with status + per-project readiness on every node, built from the caller's populated tracks (owned ∪ shared).
    • GET /management/boardRelease-Readiness: per-project release-gate rollup (reuses ProjectService.computeReleaseReadiness, doc-32), most-blocked first.
    • ManagementModule now imports ArtistSpace/Project/Comment modules; +6 unit tests (overview/matrix/board). Backend mgmt.ctrl surface = Activity · Approvals · Overview · Matrix · Board + the bulk write; only doc-38 Insights pending.
  • doc-44 RBAC — enforcement layer + doc-42 bulk write (shadow-first) (2026-06-27, Claude Code; nest build green + jest 49/49 green, lint clean). Built the migration + enforcement steps on the doc-44 foundation, shadow-first so nothing breaks, and landed the doc-42 bulk write as the first real consumer. Owner-flagged: fans = a future low-privilege principal on the Link/Guest seam (post-MVP) — recorded in doc-44 §open-questions.
    • GrantBackfillService + scripts/backfill-grants.ts (doc-44 migration step 1) — every resource's owner + each collaborators[] entry → a User grant; every TeamShare → a Team grant; deduped to the highest role per (principal, scope); idempotent upsert. Dry-run by default, mutates only with APPLY=true (mirrors the existing backfill-script pattern). Pure buildGrantOps unit-tested (6 tests).
    • PermissionGuard + @RequirePermission(action, resourceType) (doc-44 enforcement) — the single guard reads the decorator, builds the resource scope chain, gates via PermissionService. Shadow vs enforce via PERMISSION_ENFORCE: unset/false (default) never blocks — only logs where the grant model would deny (surfacing divergences pre-flip); =true enforces (default-deny).
    • Closed the track.update gapPUT /track/:id (previously no role check) now carries @UseGuards(PermissionGuard) @RequirePermission(Edit, Track), in shadow mode (logs only until the flip). TrackModule imports PermissionModule.
    • PATCH /management/bulk (doc-42 §6, the original goal) — fan setStatus / approve / reject across many tracks, gated per-node via PermissionService (a node the caller can't write is skipped, not failed); wraps existing track.update / updateReleaseStatus; returns {total, succeeded, skipped, failed, results[]} so partial success is explicit. Requires the backfill to have run (no grants ⇒ all skipped, by design — never a silent over-grant). 7 bulk unit tests.
    • Rollout (owner-run, each reversible): npx ts-node scripts/backfill-grants.ts (dry run) → re-run with APPLY=true → boot + watch [shadow] would DENY logs to confirm parity → set PERMISSION_ENFORCE=true to enforce. Verification boundary: build + 49 unit tests + lint are green; runtime DI/boot needs a Mongo connection (npm run dev) — not run in this environment.
  • doc-44 RBAC — foundation: Grant model + PermissionService resolver (non-breaking) (2026-06-27, Claude Code; nest build green + jest 37/37 green, lint clean). The concrete, runnable core of the doc-44 authorization redesign — built for alignment review before any enforcement flips on. No routes touched, zero behavior change. New src/modules/permission/:
    • Grant schema (models/grant.schema.ts) — the single authz primitive: (principalType user|team|link, principalId, scopeType ArtistSpace|Project|Track|File, scopeId, role, effect allow|deny, createdBy). Indexed for resolution + a unique (principal, scope) index for idempotent backfill. Reuses Role/SharedResource from collaboration.
    • permission.actions.ts — the doc-44 action matrix as a data-shaped constant (ROLE_ACTIONS, ROLE_LEVEL, roleCan, highestRole): view/comment/edit/upload/approve/delete/share/transfer, owner ⊇ admin ⊇ editor ⊇ viewer. Swappable for doc-43 custom roles without touching call sites.
    • PermissionServicecan(user, action, scopeChain) + resolveRole(...) computed live from the grant ledger + accepted team membership, walking node→ancestor scopes. Default-deny; most-specific scope wins → deny beats allow → highest allow-role wins (doc-44 §resolution). Short-TTL membership cache (+ clearCache); scope-chain builders (track/project/artist, populated-or- id tolerant); a shadowCompare(legacy, resolved) helper for the migration. Not injected into any route yet.
    • 15 unit tests proving the semantics: direct + cascade + most-specific-override + highest-role + deny-beats-allow + closer-deny-over-ancestor + default-deny + team-grant + the action matrix + scope-chain builders. PermissionModule registered in app.module.ts (exports the service).
    • Deferred to post-alignment (doc-44 §sequencing): backfill script (collaborators[]+TeamShare → grants), shadow-mode wiring, the @RequirePermission guard (closes the track.update default-allow gap), read-path convergence onto accessibleIds, then the doc-42 bulk write on top.
  • 44-RBAC-TEAMS-PERMISSIONS.md — the authorization redesign envisioning doc (2026-06-27, Claude Code; owner-raised). Supersedes RBAC-DESIGN.md. Surfaced by the doc-42 mgmt.ctrl bulk write — the first feature mutating many aggregated nodes, which can't lean on the implicit "you only know IDs you can see" that masks today's gaps. Finding (verified 2026-06-27): authN (JWT) is fine and reads are safe by query-scoping, but write authZ is inconsistentupdateReleaseStatus gates owner/admin while track.update does not (track.service.ts:1365 → a default-allow gap protected only by ID obscurity); the Team entity exists but fans out to per-resource collaborators[] (a bulk-add convenience, not a live permission); and there is no central resolver (grep: no PermissionService/canUserPerform/getCollaboratorRole). 6 decisions locked at envisioning (2026-06-27, owner): (1) one central PermissionService.can(user, action, resource) + Nest guard, default-deny, callable from services too; (2) computed-live + cache (ReBAC / Google-Zanzibar model) — resolve from team membership + scope assignments + grants at access time, stop materializing per-resource roles (retire the fan-out); (3) unified Grant = (principal, role, scope) with principal ∈ User · Team · Link/Guest (teams = scaling, direct grants = solo/one-off, links = guests — doc-26/38); (4) cascade with override (most-specific scope wins → highest role within scope → explicit deny beats allow); (5) two role axes kept separate — team-governance role (TeamMember.teamRole) vs access role (owner/admin/editor/viewer); (6) shadow-mode migration — backfill grants from collaborators[] + TeamShare, run the resolver compare-only (log divergences, don't enforce) before flipping; non-breaking + reversible. Action-matrix is data-shaped (→ doc-43 custom roles); grant changes ride the doc-41 audit spine; teams = premium (doc-31); reads later converge onto accessibleIds() so see-vs-change can't drift. First consumer = doc-42 bulk write (resumes at the guard step); mgmt.ctrl reads already shipped are safe and unchanged. Foundational — sequence before any further mutation surface. Registered in 00-ROADMAP.md §8; RBAC-DESIGN.md banner-marked superseded.
  • doc-42 mgmt.ctrl slice 1 — backend /management/* aggregate endpoints (2026-06-27, Claude Code; nest build green + jest 22/22 green). The first build off the doc-42 envisioning — owner-chosen backend-first, scoped to the two pre-telemetry views (Activity Timeline hero + Approvals Queue). New src/modules/management/ module (controller + service + spec), registered in app.module.ts next to MobileModule; JWT-gated on /management, @GetUser()-scoped.
    • GET /management/activity?limit= — cross-catalog activity feed, newest-first digest. Merges the user's notifications with audit-derived "track touched" events; every row carries a provenance breadcrumb (roster › artist › project › track) + a target for doc-36 peek-then-go; notifications are enriched with provenance when their pointerId resolves to one of the user's tracks. Uses the interim source doc-42 specifies (notifications + audit timestamps) since the doc-38 event log isn't built — same shape will sit over the events backbone when it lands. Limit capped at 100 (default 30).
    • GET /management/approvals — cross-catalog approvals queue, oldest-first. Lifts the MobileService pending-approvals logic from the single-user home to the manager's full owned ∪ collaborator ∪ shared set; each item = track + provenance + under-review dimensions (Title/Version/Cover) + per-version requestedBy/requestedAt + computed ageHours. One batched FileService.findUnderReviewByTrackIds query (no N+1).
    • Pure composition over Track/Notification/File services — no new domain state, no schema. Matches the MobileService pattern. Next slices: Matrix/Board/Insights views, the PATCH /management/bulk write wrapper (doc-42 §6), scope/roster filters, and a client (web or iOS, TBD).
  • 43-CONFIGURABLE-ATTRIBUTES.md — the "LEGO" configurable-attribute engine envisioning doc (2026-06-27, Claude Code; owner-raised, split out of doc-42 §8). The composable data substrate under mgmt.ctrl (doc-42): entities carry different attribute sets, the same attribute can live at different hierarchy levels, catalogs are per-workspace definable. Consolidates every deferred customization thread (doc-17 Lego OS · doc-20 §4 customization/templates · doc-41 per-workspace status catalog · doc-31 monetization). Finding (verified 2026-06-27): net-new, no foundation — all hierarchy schemas are fixed @Prop; main.ts forbidNonWhitelisted actively rejects unknown top-level fields (so attrs must live in one whitelisted bag, validated service-side); only seeds = unused File.metadata: any (file.schema.ts:68) + tags[]; lifecycle-status.ts is the exact precedent to generalize (stable key + i18n label + per-entity catalog + @IsIn); no workspace container — ArtistSpace is the de-facto tenant root; monetization rails exist (user.subscription Free/Explorer/Pro/Diamond). 3 decisions locked at envisioning (2026-06-27, owner): (1) typed attributes bag + per-workspace AttributeDefinition catalog ("EAV-lite": key/label/type/levels[]/ options/required/default/origin, service-layer validated — not full per-value EAV nor dynamic Mongoose schemas); (2) global presets + workspace override — ship default catalogs that work day one (free), workspaces tweak/extend/retire (premium), definitions inherit down the hierarchy (matches doc-41 "fixed defaults now, customizable later"); (3) authoring = both — inline quick-add a field from mgmt.ctrl + a full Workspace Settings catalog editor. Attribute-at-different-levels via levels[] + opt-in cross-level inheritance. statusCode/tags fold in as the first managed definitions. Premium / post-MVP layer (gate catalog writes on subscription; reading existing values never gated → graceful downgrade); default presets can ship early as fixed catalogs (cheap, like lifecycle-status); does NOT block doc-42 (which consumes the effective catalog). Envisioning only — no build this pass. Registered in 00-ROADMAP.md §8; doc-42 §8 forward-link updated.
    • 5 detail decisions resolved same day (2026-06-27, owner): (1) home = a dedicated WorkspaceSettings doc (room to grow for doc-20 §4 theming/saved-views); (2) existing fixed fields (ISRC/UPC/genre/…) are reconciled into the catalog as managed presets, not duplicated — available per level/required, so users only see fields they need; (3) cross-level inheritance is opt-in per definition (inherit); (4) ref type supported (values pointing at other nodes; coordinate with doc-28 relations); (5) statusCode + tags fold in as the first managed definitions. §Open-questions → §Decisions resolved.
  • 42-MANAGEMENT-CTRL.mdmanagement.ctrl Management Command Center envisioning doc (2026-06-27, Claude Code; owner-raised). A Power-BI-grade eagle-view over the whole catalog (roster → artist → project → track → version) for managers/A&R/admins/execs — status · pending approvals · release-readiness · notes · listening engagement — without drilling node-by-node, and read+WRITE incl. bulk (the Portals top-down synthesis vs per-node Portals). Key reframing: this revives & supersedes doc-17 §5 "Hierarchical Management Control" (the parked /manage vision) and extends doc-38 (its analytics engine); it is the sibling of, not the same as, doc-37 Home (Home = "what needs my attention"; this = "the whole catalog's state, cross-actor"). Six decisions locked at envisioning (2026-06-27, owner): (1) new doc extending doc-38 (38 = telemetry/engagement/heatmap engine, 42 = the eagle-view product surface); (2) one unified surface — operational + analytical together, analytical cells in a needs-telemetry state until the doc-38 backbone ships, operational widgets run on today's schema; (3) platform-agnostic — calm cards on phone → dense matrix/grid on wide screens, lead platform chosen at slice time; (4) hero = the cross-catalog Activity Command Timeline, every row a provenance portal to source (doc-36 peek-then-go); (5) read and write incl. bulk — inline edit · per-row decisions · bulk actions that wrap existing mutation endpoints with per-node role checks + partial-failure reporting (no new write semantics); (6) "LEGO" configurable attributes = companion, out of scope → proposed doc-43 (management.ctrl reads/writes whatever attributes exist, so it does not block on the attribute engine).
    • Findings (verified 2026-06-27): no cross-workspace aggregation exists — queries are per-resource; the only aggregate is single-user GET /mobile/dashboard → net-new /management/* aggregate endpoints + a PATCH /management/bulk wrapper are the real work. The activity hero's hard dependency is an event log (today only createdBy/modifiedBy + notifications exist; the doc-38 events backbone becomes the activity SoT, with an interim notifications+audit feed). The configurable-attribute ("LEGO") engine is net-new — fixed @Prop schemas + main.tsforbidNonWhitelisted actively blocks unknown fields; only seeds = unused File.metadata: any (file.schema.ts:68) + tags[]; lifecycle catalog flagged deferred (lifecycle-status.ts:6). Recommended next: author 43-CONFIGURABLE-ATTRIBUTES.md to consolidate the deferred doc-17/20/41/31 customization threads. Build after doc-37 + first-byte perf + the doc-38 backbone. Registered in 00-ROADMAP.md §8 doc-map; doc-17 §5 marked superseded.
    • Follow-up resolutions (2026-06-27, owner): working label mgmt.ctrl (revisit branding before any public string); top-nav destination for now (Home being re-designed concurrently); surface is a multi-view command center — Timeline (default) · Matrix · Queue · Board · Insights · Saved — built open/composable via a view+widget+action registry (decoupled widgets, "open, not hooked-in"; per-workspace configurable later via doc-43, same seam); action model at three altitudes (global toolbar · per-row · bulk, all role-gated). Placeholder/fixture-data-first — design & test the whole surface against mock /management/* responses at the registry binding layer, real data swaps in behind the same bindings per widget. doc-43 greenlit (the LEGO attribute model is not clearly specified in current docs → warrants its own envisioning; separate task, does not block). MVP minimum-lovable centre = Timeline + Queue (both pre-telemetry).
  • 38-ANALYTICS-INSIGHTS.md — analytics/insights envisioning doc (2026-06-26, Claude Code; stakeholder-raised). Captures the "opens / minutes / most-listened part" ask + actor-driven metrics. Key finding: no telemetry exists todayTrack.lastAccessedAt is a single overwritten timestamp (recent-access), not an event log; "most-listened part" forces an append-only events backbone + aggregation, not counters. Decision locked at envisioning: build both owner-BI and in-product actor metrics on one unified pipeline (also feeds doc-36 seen/view — no second telemetry path). Hero surface = intensity heatmap on the existing waveform; Workspace Insights role-gated (doc-20); Home gets a pulse card only. Capture-first slice sequence (ingestion + playback heartbeat ship before any read surface); privacy = aggregate-default / identified opt-in / raw-event TTL. Build after the active doc-37 Home Dashboard + the standing first-byte perf priority. Registered in 00-ROADMAP.md §8 doc-map.
    • All 5 detail decisions locked same day (2026-06-26, owner): (1) own the events store as SoT, mirror to PostHog/Amplitude later (don't rebuild funnels/retention UI); (2) classify plays by depth (engagementBand: impression / sample / listen / complete) instead of discarding short plays — a first impression is signal → free hook-rate + skip/completion-rate; (3) identified visibility opt-in + role-gated, granularity via the Teams/Role model (no separate consent system); (4) aggregate-on-read (no batch rollup pipeline; sole watch-point = the per-file position histogram → escalate to a write-time bucket $inc only if it gets slow); (5) guest/link plays = identifiable unit attributed to the share link + timestamp + coarse city/country location (store derived location, not raw IP — GDPR), with optional guest self-ID. Doc §Decisions converted from "to confirm" → "locked."

Added

  • doc-41 slice 2 — lifecycle statusCode on the hierarchy (backend) (2026-06-26, Claude Code; nest build + jest 14/14 green). The workflow-stage axis, kept distinct from approval (doc-32) and soft-delete state.
    • New src/shared/lifecycle-status.ts — fixed default catalogs as stable keys + labels (i18n-ready): Artist = active/archived · Project = draft/active/released/archived · Track = draft/published/archived, with per-entity default keys + key arrays for validation.
    • statusCode added to ArtistSpace / Project / Track schemas (enum + default → a fresh record is active/draft, never blank). Added to the update DTOs (@IsIn-validated on the ArtistSpace class DTO); Track's new statusCode is separate from its legacy loose status string (deprecate later).
    • Follow-ups: a GET status-catalog endpoint (clients render labels / "customize later"); wire the doc-40 status filter/sort + a status pill in iOS/web; relax the schema enum when the per-workspace custom catalog lands. Decisions 1–2 locked (fixed defaults + these catalogs).
  • doc-41 slice 1 — audit fields createdBy/modifiedBy on the hierarchy (backend) (2026-06-26, Claude Code; nest build + jest 14/14 green). Owner decisions locked: lifecycle status = fixed sensible defaults (customization deferred); build the audit slice now.
    • createdBy + modifiedBy (ref User) added to ArtistSpace / Project / Track schemas. Set on create (= owner); modifiedBy stamped on the main PUT edit, actor threaded from @GetUser (Track now always passes the user, not only on status changes). createdAt/updatedAt already existed (timestamps: true) and serialize by default — so CreatedOn/ModifiedOn were already there, this adds the actor.
    • Updated artistSpace.controller.spec call-signature assertions (now pass user._id). 14/14 green.
    • Follow-ups: surface "Modified by · when" in iOS/web (read-only consume); stamp modifiedBy on the secondary track mutate paths (cover/version/status/rename — updatedAt already correct there); optional backfill of createdBy/modifiedBy for pre-existing records. Next slice = lifecycle statusCode.
  • 41-RECORDS-AUDIT-STATE-STATUS.md — audit / state / status (+ i18n) foundation (2026-06-26, Claude Code; owner-raised). The data layer under doc-40: CreatedBy/On · ModifiedBy/On · state (Active/Inactive) · status (Draft/Published/…) · timezone/i18n across the hierarchy. Key finding (verified against the schemas): half already exists — every model has timestamps: true (so createdAt/updatedAt are there), owner ≈ createdBy, soft-delete isDeleted ≈ Active/Inactive, approval releaseStatus (doc-29/32) ≈ a status. Real gaps: explicit modifiedBy/createdBy audit-user, a lifecycle statusCode (distinct from approval — fixed sensible defaults now, per-workspace customization deferred), and i18n (no user.timezone/locale; store UTC/render-local; status stored as stable keys not display strings for localization). Core principle: four orthogonal axes — audit · state · lifecycle status · approval — don't overload one "status" field (today's loose Track.status: string should become the enum statusCode or be deprecated). Powers doc-40 filters/sorts/defined-views; extends the search module (today ArtistSpace/Project/Track names only) to comments/notes + a global Search view. Slice 1 (audit) is cheap/no-fork. 5 decisions flagged. Registered in 00-ROADMAP.md §8.

Added

  • doc-41 surfacing (iOS) — lifecycle status pill on Project cards (2026-06-27, Claude Code; xcodebuild BUILD SUCCEEDED). First client surfacing of the doc-41 slice-2 statusCode.
    • New LifecycleStatusPill + LifecycleStatus catalog (DesignSystem/Components/) — key→label/color mirror of the backend lifecycle-status.ts. Kept separate from the existing approval StatusPill (SnStatus, doc 29/32) per doc-41's four-orthogonal-axes rule. Calm: the neutral draft default is hidden, so only Active / Released / Published / Archived show a pill.
    • statusCode decoded on ProjectSummary; ProjectCard gained a statusCode param and renders the pill in its metadata row (next to the type), wired from LibraryView (Projects) + ArtistDetailView.
    • Follow-ups (companion pieces to make it visible/usable): a status picker in the Project Portal to set it (DTOs already accept statusCode); the doc-40 Status filter/sort in ViewOptionsMenu; the pill on Track/Artist surfaces; modifiedBy "by {name}" needs backend .populate (currently an id). Until status is settable in-app (or set via API), pills stay hidden (existing data + new = draft).

Changed

  • Player open flash — REAL fix: skeleton now mirrors the loaded layout; zoom kept (iOS) (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED; ⏳ on-device QA). Owner reported the flash persisted and that removing the zoom was wrong — they want the zoom. Root cause found: the player is waveform-centric (no big cover) — the cover lives in the bottom PlayerBar — but the loading skeleton showed a big 180px cover hero. So on open the zoom grew that cover, then detail landed and the cover-less layout replaced it → the cover "grew then vanished" = the flash. The earlier "zoomless seeded push" was the wrong lever and is fully reverted (zoom + .matchedTransitionSource restored on recents/approval; TrackRoute.zoom flag removed).
    • Fix: loadingState now mirrors the loaded layout — seeded title → faint version/approve/timecode bars → waveform placeholder, no big cover hero. The title stays put and the wave placeholder becomes the real wave; nothing vanishes. The cover stays in the bottom bar in both states.
    • Top-edge swipe-back (owner-raised): a downward drag starting near the top now dismisses, in addition to the left-edge swipe — simultaneousGesture with strict conditions so it never steals the scrub/scroll. ⏳ needs on-device gesture tuning. File: Features/Review/PlayerView.swift.
  • "Albums" → "Projects" + redundant section titles removed in the hierarchy (iOS) (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA.
    • "Albums" relabelled "Projects" everywhere it surfaces (drawer / quick-access via AppSection.title, and the section screen via LibraryScope.title) — the entity is a Project; "Albums" didn't match reality. Raw values (.albums) kept so stored prefs/keys are unaffected.
    • Dropped the inner "Projects" / "Tracks" section titles + count bubbles in the hierarchy browse views too — ArtistDetailView (the projects grid) and ProjectTracksView (the tracks list); the artist / project hero above already gives context. Matches the same cleanup already done on Home/Library/Artists. Files: Features/Shell/AppMenuDrawer.swift, Features/Shell/LibraryView.swift, Features/Browse/ArtistDetailView.swift, Features/Browse/ProjectTracksView.swift.
  • Player open no longer hitches + timecode = plain fade-in matching start color (iOS) (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA.
    • Opening freeze fixed (first-byte follow-up). PlayerViewModel.init ran setupRemoteCommands() + seedAudio() (→ player.replaceCurrentItem) synchronously during the navigation push, hitching the transition before the skeleton could paint. Both now run in a new onAppearSetup() called from the view's .task (after the first frame). First-byte is unaffected — the row already prewarmed the file's buffer on tap (AudioPrewarmer), so adopting it a beat later is free. Removed the now-duplicate setupRemoteCommands() from the detail-init path (single owner = onAppearSetup).
    • Focal end-time: dropped the grey skeleton box — owner preferred just the fade. The end time now simply fades in (opacity) into its fixed-width slot when known; no placeholder block.
    • End-time color now matches the start time (snForeground, not snForegroundSecondary) — the two halves read as one component in light theme (the dimmer grey looked off). File: Features/Review/PlayerView.swift.
  • Player focal timecode — no jump, no re-flash, fixed-width slot (iOS) (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA follow-up on the end-time skeleton.
    • No more position jump. The end-time sat in an auto-sized slot, so skeleton (wide) → real time (narrow) reflowed and recentered the whole 0:00 — 0:00 row. The end time now lives in a fixed-width slot (sized by a hidden 00:00), so the value just crossfades in place — the row never moves.
    • No re-flash on something already loaded. Added a process-wide duration cache by fileId (PlayerViewModel.durationCache): a file's length is immutable, so persisted or previously-probed durations render the total immediately on revisit — the skeleton now only appears on a genuine first probe of a legacy file (new uploads with persisted File.duration never show it).
    • Crossfade unified under one .font/.monospacedDigit on the slot so the timecode reads as one component. File: Features/Review/PlayerView.swift.
  • Sort menu (doc-40 slice 1) — reusable ViewOptionsMenu on Artists + Library (iOS) (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED on iPhone 17 Pro). Generalizes the roster-toggle pattern into the per-view display-prefs framework.
    • New ViewOptionsMenu + SortField (DesignSystem/Components/ViewOptionsMenu.swift) — a toolbar Menu with a Sort Picker + an optional extra slot for view-specific toggles. Persistence is the caller's (@AppStorage, keyed per view) so each view's sort survives relaunch. Sort presets: Default (server order) · Name (A–Z) · Recently updated.
    • Artists — Sort (Default / Name) + the "Group by roster" toggle now live in the same menu; sort applied to both the flat grid and within roster groups. Persisted at sort.artists.
    • Library — per-scope persisted sort (sort.albums / sort.tracks) via a dynamic-key @AppStorage; Albums = Default / Name, Tracks = Default / Name / Recently updated (Track.updatedAtDate). Menu hidden on the empty state. Sort keys are honest to the models (Albums/Artists carry no date — no fake "recent"). Files: DesignSystem/Components/ViewOptionsMenu.swift (new), Features/Browse/ArtistsHomeView.swift, Features/Shell/LibraryView.swift.
  • Player end-time skeleton + redundant section titles removed + roster opt-in/margin (iOS) (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED on iPhone 17 Pro). Owner QA on the skeleton + library/roster views.
    • Focal end-time no longer flashes 0:00. PlayerView.focalTimecode showed Text(format(duration)) = 0:00 until the duration resolved (legacy versions probe it from the network), flashing to the real time. Now it renders a SkeletonBlock while duration <= 0 and crossfades (ZStack + .opacity transition, .easeInOut(0.35)) skeleton → real total in place — no snap/jump (owner follow-up 2026-06-26). The previous PlayerBar-only skeleton didn't cover this separate big-display label. New tracks (persisted File.duration) still show the real time instantly (no skeleton).
    • Redundant inner section titles + count bubbles removed (owner: "keep the screen title, drop the second one that says the same with its count"). LibraryView (Albums + Tracks) and ArtistsHomeView flat layout dropped the inner SectionHeader(title:count:) — the in-content SectionScreenHeader already names the screen.
    • Artists: roster grouping is now opt-in + saved. Was automatic on any roster presence; now flat by default with a persisted @AppStorage("artists.groupByRoster") toggle in a new toolbar View options Menu (shown only when ≥1 artist has a roster). First instance of the doc-40 per-view display-pref pattern.
    • Roster name margin fixed. Roster section headers had negative leading padding (-SnSpacing.md, flush to the screen edge); removed → aligned to the content margin (doc-22 spacing). Files: Features/Review/PlayerView.swift, Features/Shell/LibraryView.swift, Features/Browse/ArtistsHomeView.swift.

Added

  • 40-SORT-FILTER-SEARCH.md — per-view Sort/Filter/Search envisioning doc (2026-06-26, Claude Code; owner-raised; slice 0 shipped same day). The app needs consistent Sort / Filter / Search per view with persisted prefs. Finding: a filter foundation exists (LensBar + doc-26 SavedViews) but is Artists-only + filter-only — no Sort, no Search, grouping was hardcoded-automatic. Model = a per-view view-config (Filter chips · Sort menu · .searchable), split persistence: display prefs = local @AppStorage (roster toggle = first instance), named Saved Views = server (doc-26). Each axis to its idiomatic iOS home; default stays calm (doc-20). Roster UI/UX confirmed against doc-22/26/36 (margin + opt-in). Registered in 00-ROADMAP.md §8.

Changed

  • Loading skeletons — grey shimmer covers + a loading timecode (iOS) (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED on iPhone 17 Pro / iOS 26.5 SDK). Owner: skeletons were showing the colored halo while a cover loaded, and the unknown duration showed a bare --:-- — both should read as a neutral loading skeleton (the shimmer the web app uses).
    • New Shimmer.swift — ports the web .skeleton-shimmer: Color.snSkeleton (grey #EBEBEB/#3A3A3A, added to Theme.swift), a Reduce-Motion-safe .shimmering() sweep (1.4 s, left→right), SkeletonFill (fills a cover), SkeletonBlock (inline value placeholder).
    • CachedImage now distinguishes 3 statesloading (URL in flight) vs empty (no URL / failed) vs image. A new optional loading: builder shows the grey skeleton only while fetching; a seeded decoded-cache hit still paints on the first frame (no skeleton, no flash, doc-34 win preserved); an already-shown cover stays put across a version switch (no grey flash). loading defaults to placeholder, so untouched call sites are unchanged.
    • Wired into the cover sitesArtworkHero, TrackRow, ProjectCard pass SkeletonFill for loading, so the colored halo shows only when there's genuinely no cover (identity), grey shimmer while loading.
    • PlayerBar timecode — when duration <= 0 (legacy versions probe it from the network), the total shows a SkeletonBlock (0:03 / ▓▓▓) instead of --:--, snapping to the real time once known. Files: DesignSystem/Components/Shimmer.swift (new), Core/CachedImage.swift, DesignSystem/Theme.swift, DesignSystem/Components/{ArtworkHero,TrackRow,ProjectCard,PlayerBar}.swift.

Added

  • 39-SAMPLE-SHARING.md — clip/sample-sharing envisioning doc (2026-06-26, Claude Code; owner-raised). "Share a sample, not the whole track" — a teaser (the hook) or a focused-feedback clip without exposing the full master. Extends doc-26 sharing (a link + {clipStart, clipEnd} window); inherits doc-38 guest analytics (hook-rate/listen-through for free). Load-bearing fork: (A) playback-window link (client-enforced, ~zero infra — recommended for the teaser/feedback need) vs (B) server-trimmed ffmpeg artifact (hard leak-protection — only if the master must never be downloadable). Authoring surface = waveform drag-select. Build after doc-37 + the doc-38 backbone. Registered in 00-ROADMAP.md §8.

Changed

  • Back button = icon only + long-press menu on every section + section typography normalized (2026-06-26, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA.
    • Reverted the back-button label — owner prefers just the chevron. DynamicBackButton is icon-only again (tap = clean pop, long-press = ancestor menu, swipe-back intact).
    • Long-press menu now on the section screens too. dynamicBack is applied once at the dashboard's navigationDestination(for: AppSection) wrapper, so Albums / Tracks / Shared / Teams / Recents / Artists all get the chevron + long-press → Home (the deeper Player/Album/Artist keep their richer menus). Was missing on the section lists (they used the system back).
    • Typography normalized. New SectionScreenHeader renders the brand display title in-content (sonnanceDisplay(32, .black)); applied to LibraryView (Albums/Tracks), SharedView, RecentsView, the Teams section wrapper, and ArtistsHomeView (refactored to the shared header) — previously only Artists had the big title, the rest used plain system nav titles. Files: Features/Shell/SectionScreenHeader.swift, Features/Shell/DynamicBackButton.swift, Features/Shell/DashboardView.swift, Features/Shell/LibraryView.swift, Features/Shared/SharedView.swift, Features/Review/RecentsView.swift, Features/Browse/ArtistsHomeView.swift.
  • Back button shows the parent label + buffering spinner on the play control (2026-06-25, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA on the dynamic back button.
    • Labelled back. The custom back button was a bare chevron; it now shows ‹ {nearest parent} (icon + literal, like the native back) — e.g. ‹ Album on the player, ‹ Artist on the album, ‹ Home on the artist. Tap stays a clean pop (matches swipe-back, preserves the parent's state); long-press still opens the full ancestor list. DynamicBackButton derives the label from the nearest jump.
    • Buffering spinner (no more "frozen" feel). While the player wants to play but is still pulling the cold first byte, the play thumb shows a spinner instead of a static glyph. PlayerViewModel observes player.timeControlStatus (.waitingToPlayAtSpecifiedRateisBuffering); PlayerBar renders the spinner. Files: Features/Shell/DynamicBackButton.swift, Features/Review/PlayerView.swift, DesignSystem/Components/PlayerBar.swift.
  • First byte — audio decoupled from the detail fetch (seed-through-route) (2026-06-25, Claude Code; xcodebuild BUILD SUCCEEDED). The prewarm helped, but the player still only installed the audio item after GET /track/:id returned — so first play was gated on the detail network round-trip. Now the final audio's signed URL + file id ride on TrackRoute (from the project-track rows and the home Recently-opened rows), and PlayerViewModel.init installs the item immediately (adopting the prewarmed buffer) via a new shared installItem / seedAudio, before bootstrap/fetchDetail. The detail-driven loadCurrent now skips rebuilding an already-seeded warm item (installedFileId guard). Net: tap → buffering starts (prewarm) → view appears with the item already installed → first play is warm, independent of the detail fetch. If first byte is still slow on a truly cold backend/blob after a deploy, that residual is server/infra cold-start — a min replicas / warmup change in infrastructure, not client. Files: Features/Review/PlayerView.swift, Features/Browse/ArtistsHomeView.swift (TrackRoute), Features/Browse/ProjectTracksView.swift, Features/Shell/DashboardView.swift.
  • Dynamic back button at every level + swipe-back restored + Home-track transition parity (2026-06-25, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA follow-up to the player-only dynamic back.
    • Reusable dynamicBack(_:onBack:) (Features/Shell/DynamicBackButton.swift) — tap = back one level, long-press = a Menu of ancestor jumps; pass nil to keep the system back. Applied to PlayerView (Album · Artist · Home), ProjectTracksView (Artist · Home — artist from the populated tracks), and ArtistDetailView (Home). Data-driven from each screen's own parents, so it's correct no matter how you arrived.
    • Swipe-back restored. Replacing the system back button disables the interactive pop gesture; SwipeBackEnabler (a UIViewControllerRepresentable) re-enables interactivePopGestureRecognizer with a delegate gated to non-root screens — so swipe-from-edge works on every dynamic-back screen.
    • Home→track now matches Project→track. The dashboard Recently opened and approval rows push a TrackRoute with a matchedTransitionSource (were bare trackId strings) → same zoom-push transition, and with swipe-back, the same feel as opening a track from a Project's track list. Files: Features/Review/PlayerView.swift, Features/Browse/ProjectTracksView.swift, Features/Browse/ArtistDetailView.swift, Features/Shell/DashboardView.swift.

Added

  • Dynamic back button on the player (long-press → jump up the hierarchy) (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED). Owner: the Back button was static — only back one level. Now on PlayerView, tap = back one level (normal pop), long-press = a menu of ancestors (Album · Artist · Home) to jump straight up. Built data-driven from the track's own populated parents — TrackDetail now decodes project + artistSpace (TrackHierarchyRef); the jump rewrites the host NavigationPath to a coherent chain (Artist → Album) so the new screen keeps a sensible back trail. PlayerView takes the host path binding (threaded from the dashboard's TrackRoute/String destinations); a Menu(…, primaryAction:) gives tap-vs-long-press for free. Tradeoff: replacing the system back button disables the interactive swipe-back gesture on the player; restore + extending the menu to the Album/Artist screens are follow-ups. Files: Core/Models/TrackDetail.swift, Features/Review/PlayerView.swift, Features/Shell/DashboardView.swift.

Added

  • Audio prewarm — first byte loads on tap, not after the player view (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED). Owner's top priority: cold first play (worst right after a fresh build/deploy) was awful because the AVPlayerItem was only built after GET /track/:id resolved.
    • AudioPrewarmer.shared (Core/AudioPrewarmer.swift) — on row tap, holds a muted, paused AVPlayer for the final audio so AVFoundation starts pulling the first bytes immediately (an item only buffers once attached to a player). Keyed by file id (SAS query signatures rotate per fetch, so the list URL ≠ the detail URL — but the file id is stable and we adopt the exact prewarmed item). FIFO-capped at 2 entries.
    • PlayerViewModel.loadCurrent adopts the prewarmed item (AudioPrewarmer.take(fileId:)) when the loaded version matches — detaching it from the prewarm player — so first play starts from a warm buffer. Falls back to a fresh item when nothing was prewarmed.
    • No backend change needed for the main paths. /track/project/:id and the dashboard recents both go through findOneById, which already returns audio = the final File with a signed blobUrl. iOS Track now decodes it (audioFinal / audioFinalURL / audioFinalFileId, permissive — nil on endpoints where audio is a ref array). Prewarm wired on the project-track rows (ProjectTracksView) and the home Recently opened rows (DashboardView). Approvals + the artist→project path still use a ref-array shape — a uniform audioFinalUrl in the serializer is the second pass.

Changed

  • Sections are pushed views, not sheets — unified navigation stack (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA: the icon-row / drawer sections (Artists, Albums, Tracks, Teams, Shared, Recents) opened as modal sheets; owner wants pushed views with a back button + the browse zoom transitions, for all entries (icon row, drawer, and long-press hierarchy nav from home/notes).
    • DashboardView now owns the single NavigationStack. Added a navigationDestination(for: AppSection) and switched the icon-row tap, drawer onSelect, create-artist completion, and long-press hierarchyMenu to path.append(section) (was presentedSection + .sheet). The ProjectRoute / TrackRoute / ArtistRoute destinations gained .navigationTransition(.zoom(sourceID:in:)) on the shared zoomNS.
    • Section views are now embedded content (no inner NavigationStack, no own route destinations).ArtistsHomeView takes the host path binding + shared namespace (its tiles are the matched zoom sources); LibraryView, SharedView, RecentsView render as plain content; TeamsView was already stack-free (dashboard adds its title). All onward navigation + zoom now flows through the one stack, so Artist → Album → Track is a continuous push chain with a back button to home. Files: Features/Shell/DashboardView.swift, Features/Browse/ArtistsHomeView.swift, Features/Shell/LibraryView.swift, Features/Shared/SharedView.swift, Features/Review/RecentsView.swift.

Added

  • Home QA batch — quick-access tiles, hierarchy nav, composer growth (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED). From owner device QA (2026-06-24).
    • Home quick-access row. A horizontal row of squared icon+title tiles under the wordmark (Artists · Albums · Tracks · Teams · Shared) — the most-reached sections are now one tap from home instead of two via the avatar drawer. Reuses AppSection.icon/.title; tap sets presentedSection. File: Features/Shell/DashboardView.swift (quickAccessRow / quickTile).
    • Home-row hierarchy navigation (doc-36 peek-then-go). Long-press an approval or note row → choose where to land: the track, its album, or (approvals only) its artist — instead of only the leaf. No new backend: findAllByUser already .populate('project')/.populate('artistSpace'), so the dashboard payload already carried the parents; the iOS Track model just wasn't decoding them. Added permissive project/artistSpace (TrackHierarchyRef) to Track, an ArtistRoute navigation destination (→ ArtistDetailView), and a shared hierarchyMenu on both row types. Files: Core/Models/Track.swift, Features/Shell/DashboardView.swift.

Changed

  • "Jump back in" → "Recently opened" (2026-06-24, Claude Code) — owner couldn't tell what the section meant; renamed to say plainly what it is (the tracks you last opened). DashboardView.swift.
  • Comment composer grows with the text + tap-outside to dismiss (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED). From owner QA: the keyboard sat on top of the box and tapping away didn't close it. CommentComposer TextField is now axis: .vertical (lineLimit(1...5)), bottom-aligned so the timecode chip + send button anchor to the last line as it grows; PlayerView gained a focus-only clear overlay that dismisses the composer on a tap anywhere off it (the composer sits in .safeAreaInset, so it stays tappable). Files: DesignSystem/Components/CommentComposer.swift, Features/Review/PlayerView.swift.
  • Per-actor relevance — dashboard section labels (doc-37 §0.3) (2026-06-24, Xcode agent; backend nest build EXIT:0 + iOS BuildProject BUILD SUCCEEDED).
    • Backend (mobile.service.ts): GET /mobile/dashboard now computes userRole — the user's highest role across all their tracks (Owner > Admin > Editor > Viewer) — and includes it in the payload. Two new private helpers: computeAggregateRole(userId, tracks) + getRoleOnTrack(track, userId).
    • iOS (DashboardPayload.swift): Added userRole: String? to decode the new field.
    • iOS (DashboardView.swift): DashboardViewModel stores userRole (defaults "owner" until loaded). New approvalSectionTitle computed var: Owner/Admin → "Needs your approval"; Editor → "Pending review"; Viewer → "Awaiting your review". Wired into the section header and the "See all" navigation title. The client stays dumb — the server tells it what role it is, the client renders the right label.

Fixed

  • Player/notes QA quick wins (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED). From owner device QA.
    • Waveform takes the artwork color; markers stay version-coded. Waveform gained markerAccent; the player passes the halo/artwork color for the bars and the version color for comment markers (was one accent for both).
    • Removed the scrollable dead space under comments. The Spacer(minLength: 220) below the comment list let you scroll into a void — .safeAreaInset(.bottom) already reserves the glass-stack height. Cut to a small clearance.
    • Comment time frozen at compose-open. New composeTime captured when the composer focuses; the comment anchors there instead of tracking the live playhead until send.
    • Assign/edit/delete on notes via long-press too. Added .contextMenu (same actions as the ⋯ menu) to Portal note rows. Files: DesignSystem/Components/Waveform.swift, Features/Review/PlayerView.swift, Features/Browse/PortalSharedSections.swift.

Added

  • Email/password login + Portal note assign-to-others picker (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED).
    • Email/password login. SignInWithAppleView now has an "or" email + password form under the Apple button → AuthManager.signInWithEmail (POST /user/login, returns the same AuthResponse). No backend work (endpoint already existed). Unblocks logout→login on the Simulator (where Apple Sign In can't authenticate). EmailLoginRequest model; styled fields (AuthFieldStyle), focus/submit chaining.
    • Assign-to-others picker. PortalNotesSection gains an "Assign to…" ⋯ action (shown when the host passes the node's roster) → a Liquid-Glass picker sheet of collaborators (avatar + name, current assignee checked, Unassign at the bottom) → commitAssign. Wired in the Project and Artist portals via their existing collaboratorsForDisplay ([ArtistCollaborator]); the Track portal passes none for now (assign-to-me/unassign still work there). Completes the doc-36/37 assign feature. Files (iOS): Core/AuthManager.swift, Core/Models/AuthModels.swift, Features/Auth/SignInWithAppleView.swift, Features/Browse/PortalSharedSections.swift, ProjectPortalContent.swift, ArtistPortalContent.swift.

Fixed

  • Logout/login: socket teardown + Sign-in-with-Apple button constraint + drawer appearance row (2026-06-24, Claude Code; xcodebuild BUILD SUCCEEDED). Owner QA after sign-out.
    • Socket not torn down on logout. AuthManager.signOut() cleared the keychain + user but never disconnected SocketIOManager — it kept the old session's connection open, retrying writes with a stale token ("Socket is not connected" spam) and a stale room subscription that could bleed into the next account. Now signOut() calls SocketIOManager.shared.disconnect() first.
    • Sign-in-with-Apple unsatisfiable constraint. The button had .frame(height: 52) but no width cap, so it stretched full-bleed (~392pt) past ASAuthorizationAppleIDButton's internal width <= 375 → the exact constraint break in the logs. Capped to .frame(maxWidth: 375) (Apple's documented max) + centered.
    • Drawer appearance row. Reverted the appearance icons to outline (owner preference — not filled) and rebuilt the row as a Menu matching the destination/sign-out rows (26pt icon column + SnSpacing.md) so the moon/sun aligns with the other drawer icons instead of sitting in the Picker's own layout.
    • Note: the AKAuthenticationError -7071 / Code=1000 in the logs is Apple's auth service failing at the system level — on the Simulator this happens when it isn't signed into iCloud (Settings → Sign in). Apple Sign In works on a real device/TestFlight. An email/password dev login would unblock simulator testing if wanted (backend already supports it). Files: AuthManager.swift, SignInWithAppleView.swift, AppMenuDrawer.swift, ThemeManager.swift.

Added

  • Workspace › Albums + Tracks — flat library (doc-37 #4) (2026-06-24, Claude Code; backend nest build
    • iOS xcodebuild BUILD SUCCEEDED). Two new drawer destinations (Workspace group), Albums and Tracks as separate items (owner request), listing everything the current user can reach, flat — no Artist → Project → Track drilling. LibraryView(scope:) / LibraryViewModel: Albums from GET /project (ProjectSummary grid) · Tracks from GET /track/all-tracks (own · collaborator · shared · via-space); the VM fetches only the scoped list. Each element is a portal to its source (doc 36) — album → project view, track → player — via the view's own browse stack. Backend: added .populate('image') to findAllTracksByUserId (it didn't populate covers). New AppSection.albums / .tracks. Also: appearance icons switched to filled SF Symbols (sun.max.fill / moon.fill) for clarity in the drawer's You group. Files (back): track.service.ts; (iOS): Features/Shell/LibraryView.swift, AppMenuDrawer.swift, DashboardView.swift, DesignSystem/ThemeManager.swift.
  • Navigation-interactions pass: actionable ⋯ menus + Portal note edit/delete/assign (2026-06-22, Claude Code; backend nest build + iOS xcodebuild BUILD SUCCEEDED). Owner direction: make every element navigable/actionable.
    • Dead ellipses → real menus (#6). TrackRow / ProjectCard had a decorative ellipsis icon with no action. Replaced with a reusable RowMenu driven by menuActions: [RowMenuAction] — renders a real Menu when actions are supplied, nothing when empty (no misleading affordance). Works inside a NavigationLink (the menu intercepts its own tap).
    • Portal notes: edit + delete (#5). PortalNotesSection note rows get an author-only ⋯ menu — Edit reuses the composer (pre-fills, routes submit to PUT /comment/:id), Delete confirms then DELETE /comment/:id. (Both endpoints already existed; just surfaced.)
    • Portal notes: assign-to-user (#5). New Comment.assignee (ref User, distinct from owner) — a triage dimension for a future "assigned to me" lens. UpdateCommentDto.assignee (string | null); populated in all note/rollup reads (findNotes, both rollups). iOS: PortalNote.assignee shown as "Assigned to {name}"; menu offers Assign to me / Unassign (collaborative, not author-gated) via a dedicated AssignRequest (encodes null to unassign). Assign-to-others picker = follow-up (needs the node's collaborator list). Files (back): comment.schema.ts, comment.dto.ts, comment.service.ts. Files (iOS): DesignSystem/Components/TrackRow.swift (RowMenu/RowMenuAction), ProjectCard.swift, Core/Models/Comments.swift, Features/Browse/PortalSharedSections.swift.

Fixed

  • Track list covers wiped by filterTrack (root cause) + dashboard note navigation (2026-06-22, Claude Code; backend nest build + iOS xcodebuild BUILD SUCCEEDED). Owner QA on the aggregate dashboard.
    • Covers (real root cause). filterTrack (run by findAllByUser / findAllSharedWithUser) looped for (const fileId in track.image)for…in over an array iterates indices ("0","1"), so findById("0") always failed and it rebuilt track.image = [], silently wiping the populated covers (the earlier .populate('image') was nuked immediately; covers never actually resolved on any list endpoint). Replaced with filterFileRefs (for…of, keeps populated File docs, drops only isDeleted, fetches only for raw ids). Fixes "Needs approval" covers and every other list that feeds rows.
    • Notes are portals to their source (doc 36). Dashboard "Open notes" now navigates by anchor level — track → player, project → project view (added ProjectRoute + TrackRoute destinations to DashboardView, making it a real browse root). Previously project/artist-anchored notes did nothing on tap. (Artist-level notes still carry no id in the rollup payload — not navigable yet.)

Added

  • Home Dashboard — GET /mobile/dashboard aggregate read model (2026-06-22, Claude Code; backend nest build + iOS xcodebuild BUILD SUCCEEDED). The dashboard is now one round-trip (was 5 parallel reads), and the aggregate is a purpose-built read model, not just a bundle (doc-37 §3 + §0).
    • New mobile module (MobileController / MobileService, registered in app.module, JWT-gated). GET /mobile/dashboard composes the existing services → { pendingApprovals, notes, activity, recents }. No new domain state; global SignUrls + StripPrivateTags interceptors apply automatically (covers signed, private tags stripped).
    • Precise who·when for approvals (the enrichment the per-list payloads can't give): each pending track is joined to its under-review file's submitter + submit time via one batched FileService.findUnderReviewByTrackIds (populated owner, newest-first). Approval rows now read "Cover · Under review — by Maya · 2d ago" instead of the track-updatedAt approximation.
    • iOS: new DashboardPayload / DashboardApproval (reuses Track / AppNotification / RollupResponse / CommentOwner); DashboardViewModel.load() collapses to the single call; pendingApproval is now [DashboardApproval]. On failure the dashboard keeps its last content (single point of failure tradeoff).
    • Next on this read model: per-actor relevance/ordering (Owner/Editor/Viewer emphasis, doc-37 §0.3) + Viewer "Awaiting approval" copy. Files (back): mobile/*, file.service.ts, app.module.ts. Files (iOS): Core/Models/DashboardPayload.swift, Features/Shell/DashboardView.swift.

Fixed

  • Dashboard "Needs approval" covers + richer rows (2026-06-22, Claude Code; backend nest build + iOS xcodebuild BUILD SUCCEEDED). Owner QA: approval covers were all the same green, and rows felt thin.
    • Cover bug (backend). findAllByUser / findAllSharedWithUser never .populate('image') (recents did, via findOneById), so owned/shared tracks arrived with no cover → the client fell back to a color. Added .populate('image') to both — covers now resolve anywhere those lists feed (dashboard, Artists grid).
    • All-green fallback (iOS). The approval row hardcoded a V1-green placeholder; now the per-track identity color (Theme.accent(forKey:)), matching recents.

Added

  • Home Dashboard — richer rows + "See all" (doc-37 §0 digest → drill-in) (2026-06-22, Claude Code; xcodebuild BUILD SUCCEEDED). Owner-chosen direction: section depth over section count, actor-neutral first.
    • who · when on the rows from data already in the payloads (no backend): approval subtitle now reads e.g. "Cover · Under review — 2d ago" (dimensions from releaseStatus + relative Track.updatedAt, newly decoded — the serializer already round-trips it); notes gain a "by {author} · {when}" meta line (populated owner + createdAt). (Precise per-version "who submitted" still wants the aggregate read model.)
    • Digest → drill-in. Each attention section now shows a capped preview (5) with a "See all →" header link into a dedicated full-list screen (DashboardList route + sectionListView, reusing the same row builders). "Jump back in" has no See-all (continuity, not a backlog). The VM keeps the full activity feed (was pre-capped at 6) so See-all is complete; notes See-all is bounded by the rollup limit (12). Files: Core/Models/Track.swift (decode updatedAt), Features/Shell/DashboardView.swift, track.service.ts (.populate('image')).
  • Home Dashboard slice 3 — rolled-up notes + user-wide rollup endpoint (2026-06-22, Claude Code; backend nest build + iOS xcodebuild BUILD SUCCEEDED). Completes the dashboard's four attention sections (doc-37 §2): needs-approval → open notes → activity → jump-back-in.
    • Backend — user-wide rollup. New CommentService.getUserRollup(userId) — the eagle view across every space the viewer can access (owner · collaborator · shared, via ArtistSpaceService.findAll), same unresolved/tagged digest as the per-node getRollup but with artistSpace: {$in: spaceIds} (a single indexed find on the existing denorm). Exposed as GET /comment/rollup/me (@GetUser()-scoped, static route before :id). CommentModule now imports ArtistSpaceModule (no cycle — artistSpace doesn't import comment).
    • iOS — "Open notes" section. DashboardViewModel adds a parallel /comment/rollup/me read (decodes the existing RollupResponse[PortalNote]). Rows show the note content + a provenance breadcrumb (Project › Track · V#) and the unresolved accent dot, tappable to the anchoring track's player (project/artist-level notes render non-navigable). Placed second per doc-37 §2 priority.
    • Remaining (optional, latency only): the GET /mobile/dashboard aggregate to collapse the now-5 parallel dashboard reads into 1 — the dashboard is fully functional without it. Plus the Viewer role-gate copy ("Awaiting approval"). Files (back): comment.service.ts, comment.controller.ts, comment.module.ts. Files (iOS): Features/Shell/DashboardView.swift.
  • Home Dashboard slice 2 — attention sections wired (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). The dashboard now surfaces real "what needs my attention" content — 2 of 3 attention sections with zero backend work:
    • Needs your approval — derived client-side from the tracks the viewer can already list (/track owned + /track/shared), filtered to Track.effectiveStatus == .underReview (deduped). No new endpoint — the list Track already carries releaseStatus. Tap → player (where the ApproveBar is already role-gated, so Viewer is effectively read-only).
    • Recent activity — new AppNotification model (named to avoid Foundation's Notification); DashboardViewModel fetches /notification, sorts newest-first, caps at 6. Read-only rows (title / detail / relative time / unread dot); tap-to-navigate via pointerId deferred (varies by event type).
    • Sections render only when non-empty (doc-37 §2.1); priority order approval → activity → recents. hasContent spans all three so the welcome hero only shows when truly empty.
    • The four reads (recent-access, notification, track, track/shared) fan out in parallel per load/refresh. Remaining for slice 3: the rolled-up unresolved notes section (the only one that needs a user-wide read — no per-node rollup covers it) and the GET /mobile/dashboard aggregate to collapse these 4 round-trips into 1. Role-gate refinement (Viewer header copy "Awaiting approval") also deferred. Files: Core/Models/AppNotification.swift, Features/Shell/DashboardView.swift.
  • Home Dashboard slice 1b — the dashboard root (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). Home is now the dashboard. New Features/Shell/DashboardView.swift (+ DashboardViewModel): the signed-in root — in-content SONNANCE wordmark, leading avatar → drawer (now includes .artists), trailing + create, and a "Jump back in" recents section wired to /track/recent-access (tap → player). Progressive-disclosure empty state (doc-37 §2.1): a brand-new user with no content sees a single welcome hero + "Create your first artist" CTA instead of empty section headers. The attention sections (needs-approval · rolled-up notes · activity) are scaffolded but render only once wired (slice 2), so the home never shows an empty header. RootTabsViewDashboardView. ArtistsHomeView reverted to being purely the drawer's "Artists" screen (drawer wiring removed; in-content title SONNANCEArtists). ⏳ Visual QA: launch lands on the dashboard; recents vs. welcome hero; drawer → Artists/Teams/Shared/Recents; create-artist from the hero + the +.
  • Home Dashboard slice 1a — avatar nav drawer + tab collapse (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). First build of doc-37: established the single-surface IA. New Features/Shell/AppMenuDrawer.swift — a Liquid-Glass account/navigation sheet (.thinMaterial, medium/large detents) with an identity header (avatar + name/email; storage meter deferred — client User carries no subscription yet), role-gateable destinations grouped Workspace (Artists · Teams) / Library (Shared · Recents), and a You group (appearance + sign out). AppSection enum is the destination catalogue. Wiring: RootTabsView collapsed from a 3-tab TabView to the single ArtistsHomeView root; the old trailing profile menu replaced by a leading account avatar that opens the drawer; Teams/Shared/Recents now reached from the drawer (presented as large sheets — each owns its own NavigationStack, so pushing would double the nav bar; see follow-up). .artists omitted from the drawer this slice (home is still the artist grid — it joins when the dashboard root lands). ⏳ Visual QA: avatar drawer open/dismiss, destination sheets, sign-out.
    • Follow-ups: (1b) dashboard root — swap the artist-grid home for the attention dashboard (§2), add .artists to the drawer; (2) wire the attention sections + role-gate; (3) GET /mobile/dashboard aggregate; (4) unified-stack refactor so Teams/Shared/Recents become true pushed destinations (strip their inner NavigationStacks) instead of sheets.
  • Phase opened: Home Dashboard (doc-37) (2026-06-21, owner-locked). Next phase after the first-load perf slice = the role-driven "what needs my attention" home dashboard (doc-20 §4.2). New envisioning doc 37-HOME-DASHBOARD.md; roadmap doc-index + .agent/next-brief.md Active-phase updated. One dashboard, role-gated content — not per-user-type homes (deferred). Mostly assembly of shipped features (roll-up doc-33, approval doc-32, realtime doc-35, notifications, recents — all source endpoints verified to exist); the only new backend is the optional GET /mobile/dashboard aggregate. No code yet — phase setup so it survives a context compaction. Owner decisions + nav envisioned (2026-06-21): home is the dashboard; roster + other sections move to a secondary menu — envisioned as an account-avatar Liquid-Glass drawer (NOT a tab bar, which dilutes the one-surface intent; NOT a literal edge-drawer, non-native on iOS) of role-gated destinations (Workspace / Library / You), trailing + stays create; empty state = progressive disclosure + promoted create-artist hero; Viewer gets the dashboard (approval read-only). Full reasoning in 37-HOME-DASHBOARD.md §5.
  • iOS: SONNANCE home title now renders in the brand display face (real fix) (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). Supersedes the round-2 appearance-proxy attempt: SwiftUI NavigationStack ignores UINavigationBarAppearance font for large titles (UIKit-only API), so the face never changed (only the uppercasing did). Fixed by rendering the wordmark as in-content Text in .sonnanceDisplay(.black) at the top of ArtistsHomeView with the system title hidden (navigationTitle("") + .inline) — the pattern the player title already uses. File: Features/Browse/ArtistsHomeView.swift. ("Rides up on scroll" stays as Home-header redesign #6.)
  • First-load perf slice — optimistic skeleton · persisted File.duration · prefetch-on-intent (2026-06-21, Claude Code; backend nest build + iOS xcodebuild BUILD SUCCEEDED). Attacks the remaining first-open / first-byte lag (doc-24 §Performance). Three coordinated parts:
    • Optimistic skeleton (iOS, the felt win). Opening a track no longer shows a bare centred spinner while GET /track/:id runs. TrackRoute now carries the track name + cover thumb (the list already has both), seeding the player so the hero + title paint instantly (the thumb is already in the decoded cache from the row) with faint placeholder bars for the parts that need the fetch — the layout doesn't jump when detail lands. Halo also seeds from the track's own cover first (doc-22 §6 continuity). Bare PlayerView(trackId:) entries (Recents / Shared) degrade gracefully to no-name + project cover.
    • Persisted File.duration (backend ride-along). New optional duration (seconds) on the File schema. The iOS uploader computes it locally from the AVURLAsset (instant — moov-atom parse, no decode) and sends it as a multipart text field; POST /track/:id/file coerces + validates it (parseDuration) and stores it. No server-side audio-probe dependency (dodges the ESM music-metadata/CJS friction). The player shows the timecode immediately and skips the asset.load(.duration) network round-trip; absent on images / pre-slice / web uploads, where it cleanly falls back to loading from the asset.
    • Prefetch-on-intent (iOS). The AVPlayerItem is set as the player's current item as soon as detail loads — before the user taps play — so AVPlayer buffers the first bytes while they read the screen; first play then starts without the cold-buffer wait (session stays unactivated, so it never interrupts other apps). Files (back): file/models/file.schema.ts, file/models/file.dto.ts, track/track.controller.ts, track/track.service.ts. Files (iOS): Core/Models/MediaFile.swift, Core/APIClient.swift, Features/Review/PlayerView.swift, Features/Browse/ArtistsHomeView.swift, Features/Browse/ProjectTracksView.swift. ⏳ Visual QA: confirm the skeleton paints instantly and the timecode shows before play on newly-uploaded versions (old/web versions still load duration over the network — that's expected, no backfill).

Fixed

  • iOS: first-song cold-start warm-up · player album-cover fallback · SONNANCE brand title (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). Three owner-raised polish items.
    • First-track cold-start lag (only the very first open after a fresh install). SonnanceApp now warms the AVFoundation stack off-main at launch (setCategory + a throwaway AVPlayer) so connecting to mediaserverd + initializing the audio session happens during launch, not on the first play. (The first audio byte fetch is still network-bound — inherent to a cold cache.) doc-24 §Performance.
    • Player shows the album cover for coverless tracks. PlayerBar used coverFile.blobUrl with no fallback, so a track without its own cover showed a bare gradient. Now it cascades track cover → project/album cover (coverThumbURL ?? projectThumbImageURL ?? projectImageURL), matching the hero + doc-28 display cascade.
    • SONNANCE title in the display font. The home title now renders in PPMonumentExtended-Black. (Round 3 — the real fix. Neither the legacy largeTitleTextAttributes proxy nor a full UINavigationBarAppearance applies a custom font to a SwiftUI NavigationStack large title — SwiftUI re-applies its own title rendering (the appearance font works in UIKit only; uppercasing the string "took" but the face never changed). Fixed by rendering the wordmark as in-content Text in .sonnanceDisplay(.black) at the top of ArtistsHomeView and hiding the system title (navigationTitle("") + .inline) — the same pattern the player's display title already uses successfully. The transparent UINavigationBarAppearance from round 2 stays (it correctly controls the bar background); only the font lever moved in-content.) The "rides up on scroll" treatment is the deferred Home-header redesign #6. Files: SonnanceApp.swift, Features/Review/PlayerView.swift, Features/Browse/ArtistsHomeView.swift.

Added

  • iOS: CachedImage sweep — every image now uses the decoded cache; font verified (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). Completes the decoded-image-cache rollout — zero AsyncImage remain in the app (outside CachedImage itself).
    • Swapped the remaining 8 sites: player mini-artwork (PlayerBar), track/cover thumbs + hero (TrackPortalContent — also removes its ProgressView spinners), portal covers (ArtistPortalContent / ProjectPortalContent), and avatars (note + collaborator avatars in PortalSharedSections, TeamDetailView member avatars, TeamsView team avatars). Avatars/covers now layer the placeholder (initials / icon) under a CachedImage with a clear placeholder → instant on revisit, no flash, no spinner anywhere.
    • Font verified (resolves doc-24 #7): read the bundled Lexend name tables — PostScript names are exactly Lexend-Bold / Lexend-Regular / Lexend-Light, which match Typography.swift's Font.custom(...). So sonnanceTitle/Body/Caption (incl. the "Projects" SectionHeader) are rendering real Lexend now that the files are bundled + UIAppFonts-registered. Stakeholder-approved; nothing to change — any remaining dislike is a styling choice (weight/treatment), not a wrong font. Files: DesignSystem/Components/PlayerBar.swift, Features/Review/TrackPortalContent.swift, Features/Browse/ArtistPortalContent.swift, Features/Browse/ProjectPortalContent.swift, Features/Browse/PortalSharedSections.swift, Features/Teams/TeamDetailView.swift, Features/Teams/TeamsView.swift.
  • iOS: decoded-image cache — kill the "default cover" flash app-wide (doc 34; CDN de-prioritized) (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). The real root cause of the laggy/flashy cover loads: AsyncImage always cycles .empty → .success, even when the bytes are cached — because the doc-34 URLCache only holds raw bytes that still need an async decode. That one placeholder frame is the flash, and it repeated on every appear / scroll / zoom.
    • DecodedImageCache (new Core/CachedImage.swift) — a process-wide NSCache<NSURL, UIImage> of decoded images (~96 MB, cost-weighted). The missing tier above URLCache (bytes/disk).
    • CachedImage view — seeds from the decoded cache in init, so a previously-seen cover paints on the first frame (no placeholder, no flash); a miss shows the placeholder, fetches via the URLCache- backed URLSession, decodes off the main thread (preparingForDisplay), stores it, and crossfades it in. Drop-in for the success/placeholder AsyncImage pattern.
    • Swapped AsyncImage → CachedImage in the cover components: ArtworkHero (heroes + artist tiles), ProjectCard, TrackRow. The card populating the cache means the zoom destination hero reads the same decoded cover instantly → the Artist→Project flash is gone at the source. (Avatars + remaining AsyncImage sites are a follow-up sweep.)
    • CDN de-prioritized (owner call): the decoded cache is the short-term, free answer to cover performance; a CDN (doc-05 Front Door) is expensive and not a must — explicitly moved to a far-future scale lever, not near-term (doc-34 updated). Files: Core/CachedImage.swift (new), DesignSystem/Components/ArtworkHero.swift, DesignSystem/Components/ProjectCard.swift, DesignSystem/Components/TrackRow.swift.

Fixed

  • iOS: kill the cover flash + loaders in the zoom transition; version-color the activity feed (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). Owner-raised, post-QA of the zoom transition + ACTIVITY feed.
    • No more spinner during the zoom. ProjectTracksView only rendered the hero after tracks loaded — so the zoom revealed a centered ProgressView. Restructured content so the hero always renders immediately (seeded from the route) and the tracks list fades in beneath it; the full-screen spinner is gone (owner: "get rid of loaders").
    • No more cover pop. ArtworkHero's AsyncImage always cycles .empty → .success (even cached), which read as a flash. Now it crossfades the cover in over the identity-colored gradient (Transaction(animation:) + .opacity transition) — doc 22 §4 "nothing pops." Applies everywhere ArtworkHero is used (tiles + heroes).
    • Activity items follow their variant's color. A rolled-up version comment now takes that version's color code (Theme.versionColor): the kind chip, a new V{n} capsule, and the timecode chip are all tinted by the version color, so comments read as grouped by their variant. Notes (no version) keep the accent. Files: Features/Browse/ProjectTracksView.swift, DesignSystem/Components/ArtworkHero.swift, Features/Browse/PortalSharedSections.swift.
  • iOS: roll-up polish — level-context label, version suppression, Artist→Project zoom flash (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED). Three owner-raised items off the consolidated ACTIVITY feed + the new zoom transition.
    • Here → level context. The generic HERE tag on a node's own annotations now reads the level (ARTIST / PROJECT / TRACK) + the node name below it (PROJECT · Demo Place) — a "where we are" reminder (doc 36). anchorName threaded into PortalNotesSection from the three portal contents.
    • Version level suppresses it — at the version (.file) level a timecoded comment already implies "this version", so the level tag + name are hidden (owner-raised; doc 36).
    • Artist→Project zoom flash fixed. ProjectTracksView didn't seed artworkAccent in its init (unlike ArtistDetailView), so during the zoom the project halo started at the identity default and crossfaded to the cover color — a flash. Now it seeds from the navigating card's already-cached cover color (projectThumbImageURL ?? projectImageURL), so the halo is correct on the first frame (doc 22 §6). The Roster→Artist hop never flashed because ArtistDetailView already seeds. Files: Features/Browse/PortalSharedSections.swift, Features/Browse/ArtistPortalContent.swift, Features/Browse/ProjectPortalContent.swift, Features/Review/TrackPortalContent.swift, Features/Browse/ProjectTracksView.swift.

Added

  • iOS: zoom-into-cover hierarchy navigation transition (doc-36) (2026-06-21, Claude Code; xcodebuild BUILD SUCCEEDED — needs visual QA + halo-timing tuning). The browse pushes (Home→Artist→Project→Track→ Player) popped, fighting the identity gradient/halo at the top of every screen. Now the tapped cover is the matched source and each destination zooms from it, so the gradient reads as continuous through the hierarchy — the unbuilt navigation half of doc-22 §4 ("nothing pops") + §6 (halo continuity).
    • One shared @Namespace zoomNS in ArtistsHomeView (the NavigationStack owner), threaded down to ArtistDetailView + ProjectTracksView (whose project/track source tiles live in child views).
    • Sources: .matchedTransitionSource(id:in:) on the artist tile (Home), project card (ArtistDetailView), track row (ProjectTracksView) — via a small View.zoomSource(_:in:) helper that no-ops when the namespace is absent (so a view built outside the stack just falls back to a normal push). Destinations: .navigationTransition(.zoom(sourceID: route.id, in: zoomNS)) on each navigationDestination.
    • The halo is already identity-keyed (doc-22 §6), so it interpolates across the push for free. iOS-18 APIs; graceful fallback to a normal push when a source isn't found.
    • Remaining: visual QA + tuning against the halo crossfade timing (don't double-animate), per the doc-36 §Navigation-transitions note. Implementation is compile-verified; the polish pass is visual. Files: Features/Browse/ArtistsHomeView.swift, Features/Browse/ArtistDetailView.swift, Features/Browse/ProjectTracksView.swift.
  • Portal annotations consolidated into one ACTIVITY feed (2026-06-21, Claude Code; nest build clean + iOS xcodebuild BUILD SUCCEEDED). Owner-raised: the Notes band + the roll-up band were two separate sections — merge them. Now one ACTIVITY section per Portal (doc-33 §Portal annotations, doc-36):
    • Two zones, one feed: the node's own annotations (tagged Here) pinned on top, then the rolled-up subtree digest (provenance breadcrumb) below. Compose stays on top; the unresolved count moved into the section header. Default sort = unresolved-first, then newest within each zone.
    • Backend — findNotes scoped to the EXACT anchor (comment.service.ts). Post-denorm, every note carries project/artistSpace, so a bare {project} match returned the whole subtree; the "Here" zone must be the node's own notes, so the query now excludes deeper anchors ({project, track:{$exists:false}, file:{$exists:false}}, etc.). This also makes the existing "Anchored here" band correct.
    • iOS (PortalSharedSections.swift): hereItems (= scoped findNotes) + belowItems (= roll-up, de-duplicated against the Here ids so a here-note isn't shown twice), each digestSorted; noteRow gained the Here capsule; the separate rollupBand removed (items now render inline below the Here zone).
    • Sort control (Here-first | Newest | Unresolved) is the deferred follow-on (default ships now).

Fixed

  • iOS: Post button always reachable above keyboard (2026-06-21, Xcode agent; BuildProject green). Bug: in PortalNotesSection.composeView, the inline Post button sits below the tags field and scrolls off-screen when the software keyboard is raised. Fix: added a keyboard toolbar (ToolbarItemGroup(placement: .keyboard)) to the compose VStack so a Post button appears pinned above the keyboard whenever either TextField (content or tags) is focused — the inline Post button is retained as well. Disabled state mirrors the inline button (empty content or in-flight post). File: Features/Browse/PortalSharedSections.swift.

  • iOS: note compose field hidden by keyboard + rollup tap hitch (2026-06-20, Claude Code; BuildProject green). Two QA fixes from owner testing of the doc-33 roll-up iOS slice.

    • Compose field scrolls into view (Portal.swift + PortalSharedSections.swift): added .scrollDismissesKeyboard(.interactively) to the Portal's ScrollView so a downward swipe dismisses the keyboard without losing the compose state; wrapped PortalNotesSection.body in ScrollViewReader and added .onChange(of: showComposer) to call proxy.scrollTo("portal_notes_composer", anchor: .bottom) (with a 150 ms delay so the view is inserted before the scroll fires) — ensures the TextField is visible when the keyboard rises.
    • Rollup tap hitch reduced (PortalSharedSections.swift): (1) removed the withAnimation(.easeInOut) wrapper from the onTapGesture toggle — SwiftUI can't smoothly interpolate lineLimit changes and the animation was the source of the layout-measurement hitch; (2) deferred .textSelection(.enabled) to expanded state only (using if isExpanded branching, since .enabled/.disabled are non-substitutable concrete types) — eliminates gesture-recognizer overhead on every collapsed row. Files: DesignSystem/Components/Portal.swift, Features/Browse/PortalSharedSections.swift.
  • Backend: "Couldn't post note" + missing provenance on anchored notes — inconsistent populate (2026-06-20, Claude Code; nest build clean, lint clean). Root cause: the doc-33 iOS slice extended PortalNote to decode track/project/file as populated objects (for roll-up provenance), but createNote (the POST /comment/note response) and findNotes (GET /comment/notes) only populated owner — so those refs came back as raw ObjectId strings. decodeIfPresent hits a type mismatch (string where an object is expected) and the whole decode throws → "Couldn't post note" (the note WAS created server-side; only the response decode failed) and no breadcrumb on the "Anchored here" notes. Fix: createNote + findNotes now populate the same provenance shape as the roll-up (track/project name shortId slug, fileversion type), so every annotation response decodes identically and notes carry their breadcrumb too. (createCommentByFile's wave payload is decoded as TrackComment, which ignores those fields — left as-is.) File: comment/comment.service.ts.

Added

  • iOS: doc-33 eagle-level roll-up — provenance breadcrumb, peek-then-go navigation, touch-text, roster nits (2026-06-20, Claude Code; BuildProject green). Completes the iOS half of the doc-33 eagle-level roll-up slice.

    • PortalNote model now decodes track/project/file provenance fields populated by the backend rollup endpoint (RollupProvenanceRef + RollupFileRef in Comments.swift). Nil-safe — notes-only responses unaffected.
    • Rollup band (PortalSharedSections.swift): each item now shows a provenance breadcrumb (e.g. "Demo Place › Track 3 › V2") trimmed to whatever the backend populated. Items expand on tap (peek-then-go, doc 36): collapsed = 2-line truncated; expanded = full content + breadcrumb + timecode + "Open in {trackName} →" button. The "Open" button fires onNavigateToTrack(trackId, timeStart?), threaded from PortalNotesSectionArtistPortalContent/ProjectPortalContentArtistDetailView/ProjectTracksView; the host view sets showPortal = false and pushes a TrackRoute onto the nav path once the sheet dismisses. Long-press → context menu (Copy text / Mark resolved). .textSelection(.enabled) on both note content and rollup item content.
    • commitToggleRollupResolve added — optimistic resolve toggle for rollup items (separate from commitToggleResolve which targets the "Anchored here" notes array).
    • Roster nits (ArtistsHomeView.swift): (1) Roster section header count trailing margin fixed — changed .padding(.horizontal, -SnSpacing.md) to .padding(.leading, -SnSpacing.md) so the count text has a layout-gutter margin from the screen edge. (2) Per-artist roster capsule suppressed in grouped layout (showRoster: Bool = true on ArtistTile; groupedContent passes false since the section header already names the roster). Files: Core/Models/Comments.swift, Features/Browse/PortalSharedSections.swift, Features/Browse/ArtistsHomeView.swift, Features/Browse/ArtistPortalContent.swift, Features/Browse/ArtistDetailView.swift, Features/Browse/ProjectPortalContent.swift, Features/Browse/ProjectTracksView.swift.
  • Backend: doc-33 eagle-level roll-up — notes & comments surface up the hierarchy (2026-06-20, Claude Code; nest build clean, jest 14/14, 0 new lint errors, boot-verified/comment/rollup + /comment/rollup/counts mapped, no DI errors). Owner-confirmed model (doc-33 §Surfacing UX, decisions 6a–6d): a node's Portal shows a rolled-up digest of child annotations, and cards/rows everywhere get an ambient count badge — one unified roll-up (notes + comments), digest-filtered (unresolved · tagged).

    • Denormalized ancestor chain. Every Comment now carries its full lineage track/project/ artistSpace (indexed), set on create from the most-specific anchor (resolveAnchors: file→track→ project→artist). The timeline create derives project+artist off the track it already fetches (no extra query); createNote resolves the chain (a project-anchored note gets its artist via one Project lookup — the Project model is now registered in CommentModule). So a subtree roll-up is a single indexed find, correct across children the client never loaded.
    • GET /comment/rollup?{trackId|projectId|artistId}=X{ counts: {total, unresolved, tagged}, items: [capped, newest-first] } — the Portal "rolled-up" band. GET /comment/rollup/counts?type=&ids=a,b,c → batch { id: counts } via one aggregation — powers the badge across a whole grid in one request. Both declared before @Get(':id').
    • Provenance on roll-up items (owner-raised 2026-06-20) — items now populate track/project names (+ shortId/slug) and file.version, so a rolled-up annotation can show its source path (📝/💬 · roster › artist › project › track › V#) and the client can deep-link to the exact anchor. Grounds the new 36-INTERACTION-PATTERNS.md (envisioning: provenance + peek-then-go navigation, touch-text select/copy/edit, the Roster Portal, and "view"/seen events).
    • Filter = digest (decision 6b): unresolved OR tagged. The per-actor lens (awaiting-my-approval, #legal) layers on the client (doc-26). Roster badge = the client sums its artists' counts (no new endpoint).
    • Backfill scripts/backfill-comment-ancestors.ts (dry-run-gated) denormalizes ancestors onto existing comments so they show in roll-ups. Run it after deploy (existing comments lack the denorm).
    • Deferred/flagged: access-gating the roll-up endpoints (matches the existing comment reads, which don't gate — hardening follow-up); the per-actor lens; live-emit of count changes; doc-32 approval-status in the "status-bearing" filter. Next = iOS (badge on Artist/Project/Track/Roster cards + the Portal band). Files: comment/comment.service.ts, comment/comment.controller.ts, comment/comment.module.ts, comment/models/comment.schema.ts, scripts/backfill-comment-ancestors.ts.

Fixed

  • iOS+backend: "Couldn't load notes" — APIClient corrupted query-string URLs (2026-06-20, Claude Code; iOS xcodebuild + backend nest build both green). Root cause: APIClient.buildRequest built URLs with APIConfig.baseURL.appending(path:), which treats the whole string as one path component and percent-encodes the ?. The doc-33 notes call (GET /comment/notes?artistId=…) was the first iOS GET with a query string, so the ? became %3F: the server received /comment/notes%3FartistId=… as one segment, missed @Get('notes'), fell through to @Get(':id'), and findById("notes?artistId=…") threw a Mongoose CastError → 500 → "Couldn't load notes." (My earlier "env/deploy" diagnosis was wrong — it was a URL-builder bug, not freshness.)
    • iOS fix: new APIClient.makeURL(path:) splits path vs query via URLComponents (preserving percentEncodedQuery) when the path contains ?; non-query paths keep the old appending(path:). Fixes every current + future query-string GET, not just notes. File: Core/APIClient.swift.
    • Backend hardening: CommentService.findById now guards isObjectId → clean 404 instead of a Mongoose CastError 500 for a malformed id (misrouted query string, bad deep-link, etc.). File: comment/comment.service.ts.
  • iOS: opening the wave player no longer stops other apps' audio + comment-box literal (2026-06-20, Claude Code; xcodebuild BUILD SUCCEEDED). Two owner-raised wave-view items.
    • Audio-session interruption. The doc-35-era configureAudioSession set the category andsetActive(true) on PlayerViewModel init, so merely viewing a track took audio focus and stopped music playing in another app. Split it: init now only sets the category (.playback + .mixWithOthers — non-interrupting), and a new activateAudioSession() (setActive(true), exclusive, re-sets the category without .mixWithOthers) fires only when the user hits play (playPause start + selectVersion resume). Other apps keep playing until you actually press play; background playback / lock-screen transport unchanged. File: Features/Review/PlayerView.swift.
    • Comment-box placeholder. The wave timeline composer said "Add a note" — confusing now that real Notes (doc 33) exist elsewhere. Changed to "Add a comment" (it's the time-synced comment box). File: DesignSystem/Components/CommentComposer.swift.

Added

  • iOS: doc-33 eagle-level roll-up — ambient badges + Portal activity band (2026-06-20, Xcode agent; BuildProject green, 0 diagnostics across 8 files). Surfaces roll-up counts on every card/row and adds an "ACTIVITY" rolled-up band in all three Portals — pure iOS, no backend changes.
    • RollupCounts + RollupResponse added to Comments.swift; PortalNote extended with type? + timeStart? so the roll-up band can decode both NOTE-kind and timeline-comment items.
    • Ambient badge on cards/rows — amber capsule showing unresolved count, hidden when 0: ProjectCard (.topLeading artwork overlay), TrackRow (inline before status pill), ArtistTile (.topTrailing circle overlay). One batch request per grid: GET /comment/rollup/counts?type=&ids=a,b,c loaded best-effort after main data in ArtistsHomeViewModel, ArtistDetailViewModel, ProjectTracksViewModel. Roster section headers show a client-side-summed badge across their artists' unresolved counts.
    • Portal "ACTIVITY" band in PortalNotesSection — loaded in parallel with notes via GET /comment/rollup?{param}=id. Shows unresolved count, then per-item rows: kind chip (note vs feedback), author + relative date, optional timestamp pill, 3-line content preview, resolved checkmark. File-anchor skips the band (endpoint is track/project/artist only). Tap-to-navigate is a flagged follow-on. Files: Core/Models/Comments.swift, DesignSystem/Components/ProjectCard.swift, DesignSystem/Components/TrackRow.swift, Features/Browse/ArtistsHomeView.swift, Features/Browse/ArtistDetailView.swift, Features/Browse/ProjectTracksView.swift, Features/Browse/PortalSharedSections.swift.
  • iOS: doc-33 v1 — Portal Notes section (Artist / Project / Track portals) (2026-06-20, Xcode agent; BuildProject green, 0 diagnostics across 5 files). Surfaces the backend NOTE-kind Comments in all three Portals — pure iOS UI, no backend changes.
    • PortalNote + CreateNoteRequest added to Comments.swift — mirrors the backend Comment schema (_id, content, owner (populated), createdAt, isResolved, tags[]). CreateNoteRequest custom-encodes with nil-omission so the backend's whitelist + forbidNonWhitelisted pipe never rejects; uses artistSpaceId (not artistId) matching CreateNoteDto.
    • NoteAnchorType enum maps artist / project / track / file to the correct query param (artistId, projectId, trackId, fileId) for GET /comment/notes?… and to the correct body field for POST /comment/note.
    • PortalNotesSection (new reusable component in PortalSharedSections.swift) — "NOTES" section header + "+" toggle to open inline compose; per-note rows show avatar (initials + themed accent, async image), author name, relative date (RelativeDateTimeFormatter), content, tag chips (horizontal scroll, #-prefixed); resolve toggle (circle → green checkmark, optimistic flip with server revert on failure); empty state; loading and error states.
    • Wired into ArtistPortalContent (anchorType: .artist), ProjectPortalContent (anchorType: .project), and TrackPortalContent (anchorType: .track).
    • Deferred (per brief): file-level anchor, eagle-level roll-up, note live-emit / notifications, approval elevation, pinning, #Tags lens cross-hierarchy surfacing. Files: Core/Models/Comments.swift, Features/Browse/PortalSharedSections.swift, Features/Browse/ArtistPortalContent.swift, Features/Browse/ProjectPortalContent.swift, Features/Review/TrackPortalContent.swift.
  • Backend: doc-33 v1 — notes as node-anchored Comments (2026-06-20, Claude Code; nest build clean, jest 14/14, 0 new lint errors, boot-verified/comment/note + /comment/notes mapped, no DI errors). Grounds the owner's "real Notes" ask on the existing Comment entity (no new collection) — a Note is a Comment anchored to a node instead of a moment. Purely additive (no client breakage).
    • Comment extensions: CommentType.NOTE; an artistSpace anchor (notes can live at any level — artist / project / track / version); tags: string[] + shareTags — the cross-hierarchy #Tags surfacing, owner-private + opt-in. Privacy is free: the existing StripPrivateTagsInterceptor duck-types {owner, tags, shareTags}, so Comment.tags are stripped from non-owner reads automatically.
    • POST /comment/note (CreateNoteDto = content + one anchor id + tags + shareTags) → createNote, a track-tolerant path (the timeline-comment create assumes a track + notifies its collaborators; notes can anchor anywhere, so they get their own path). GET /comment/notes?artistId|projectId|trackId|fileIdfindNotes, newest-first, the Portal's "Anchored here" band.
    • Confirms doc-33 decision 1 (explicit NOTE kind) + decision 3 (per-resource, owner-private, opt-in tags — same flag as the node tags shipped 2026-06-18).
    • Deferred (flagged): the eagle-level roll-up (decision 6 — subtree comment-aggregate read); the Track.notes → Track.description rename (decision 2 — touches clients, needs coordination); note notifications + live-emit to the anchor room; opt-in approval elevation (decision 4) + pinning (decision 5). Plus the iOS Portal Notes section. Queued in next-brief. Files: comment/models/comment.schema.ts, comment/models/comment.dto.ts, comment/comment.service.ts, comment/comment.controller.ts.
  • Backend: doc-32 v1 — approval as a dimension (cover per-image + tracklist + release-gate rollup) (2026-06-20, Claude Code; nest build clean, jest 14/14, 0 new lint errors, boot-verified — new routes mapped, no DI errors). Generalizes the doc-29 audio-version approval into a dimensional model. Owner-confirmed scope: cover (per-image) + tracklist (decisions 1 & 3).
    • Cover = a per-image approved dimension. The existing PATCH /track/:trackId/file/:fileId/review-status now generalizes its mirror: an image final → releaseStatus.cover, an audio final → releaseStatus.version (unchanged). Reuses the doc-29 File.reviewStatus fields — the verdict attaches to the exact cover File, never a floating "cover." Honesty hooks: a fresh cover upload (createNewFile, image) → cover Under review + the new File Under review; setCoverImage (promote) → releaseStatus.cover follows the promoted image's verdict.
    • Cover-history DELETE (doc 32 §7.3): DELETE /track/:trackId/image/:fileIdsoftDeleteImageVersion, soft-deletes an image version and pulls it from image[], guarded — refuses the current imageFinal (promote another cover first), so the badge never points at a deleted image. Completes the cover CRUD (setCoverImage = promote was the only verb).
    • Tracklist dimension: Project.tracklistStatus { status, by, at } (reuses TrackReleaseStatus) + PATCH /project/:id/tracklist-status (owner/admin gated in the service, mirroring the doc-29 gate). Track.order already existed, so no order-model change. (Reset-on-reorder deferred — the reorder endpoint is currently commented out / unexposed; flagged in doc 32.)
    • Release-gate rollup: GET /project/:id/release-readinessread-time compute (no stored truth): every track's final audio + cover Approved and the tracklist locked → { ready, tracksTotal, audioApproved, coverApproved, tracklistApproved, tracks[] }. The Project-Portal headline + the precondition the future distribution gate reads. Computed in project.service from tracks the controller fetches via trackService.findOrderedTracks.
    • iOS Portal surfacing shipped — see entry below.
    • Remaining: Status-lens surfacing + still-envisioned dimensions (lyrics, master, phase marker) + review-history log + quorum (doc-32 decisions 2/4/5/6). Queued in next-brief. Files: track/track.service.ts, track/track.controller.ts, project/project.service.ts, project/project.controller.ts, project/models/project.schema.ts.
  • Backend: doc-35 phase 4b — real-time emit on sharing mutations (completes backend realtime) (2026-06-20, Claude Code; nest build clean, jest 14/14, 0 new lint errors, boot-verified). Extends the realtime layer to sharing, so Shared / With-me feeds can go live. With this, all backend emit for doc-35 is done — the only remaining piece is the iOS Shared-feed consumer.
    • Events → the affected member's user: room: collaborator:added (a user opens a share link → addUserToLink), collaborator:removed (owner removes someone from a space/project/track), team:shared
      • team:unshared (fan-out to each accepted team member on shareToTeam / unshare). Thin payloads ({ resourceType, resourceId, [teamId,] actorId }) per the doc-35 convention; members suppress their own echo via actorId.
    • Emitted from the services (collaboration.service, team.service) — unlike the controller-emitted hierarchy CRUD (4a), because the affected-member fan-out is computed inside the service (the team recipient list, the cascade). One event per user-action: the collaborator-remove cascade (space → projects → tracks) emits a single space-level event — the recursive removeCollaboratorFrom* calls are gated by !recursive.
    • DI: both services inject the existing @Global EventsGateway (zero module plumbing); each got a small best-effort emitToMemberFeed() (a socket failure never breaks the write). Boot confirmed the DI graph resolves (collaboration + team routes mapped, no UnknownDependencies).
    • Paired iOS follow-on (the last realtime piece): the Shared / With-me VM joins its user: room and merges these events — it already has the SocketIOManager plumbing from phase 5. Queued in next-brief. Files: collaboration/collaboration.service.ts, team/team.service.ts.

Added

  • iOS: doc-32 Portal approval surfacing (2026-06-20, Xcode agent; BuildProject green, 0 diagnostics). Wires the doc-32 backend (cover per-image approval + cover-history delete + tracklist lock + release-gate rollup) into the Track Portal and Project Portal. Pure iOS — no backend changes.
    • Track Portal — cover approval (doc 32): the Cover section now shows the current cover's approval StatusPill (sourced from detail.releaseStatus.cover). Owner/admin sees Approve / Request Changes buttons that call PATCH /track/:trackId/file/:fileId/review-status with the image file ID — the backend mirrors the verdict into releaseStatus.cover. The Label Copy Table "Track cover release" row now reflects the live status instead of a hardcoded noStatus.
    • Track Portal — cover-history delete (doc 32 §7.3): history rail items gain a red ×-trash badge. Tapping calls DELETE /track/:trackId/image/:fileId via new PlayerViewModel.deleteCoverVersion() (refetches detail on success). The badge is disabled for the current final (the server would reject it anyway — backend guard is authoritative).
    • Project Portal — tracklist lock (doc 32): new "TRACKLIST" section (always visible) shows the current tracklistStatus as a StatusPill. Owner/admin gets a Menu to set the status to any TrackReleaseStatus value via PATCH /project/:id/tracklist-status; the Portal reloads after success.
    • Project Portal — release-gate headline (doc 32): new "RELEASE READINESS" section loaded alongside the project detail via GET /project/:id/release-readiness (best-effort, never blocks). Shows: audio N/M · cover N/M · tracklist status + a Ready / Not ready StatusPill. Refreshes after each tracklist status change.
    • Models added:
      • TracklistStatus struct (in Project.swift) — decoded from ProjectDetail.tracklistStatus.
      • ReleaseReadiness + ReleaseReadinessTrack (in ReleaseStatus.swift) — response shape of GET /project/:id/release-readiness.
      • UpdateTracklistStatusRequest — body for PATCH /project/:id/tracklist-status. Files: Core/Models/Project.swift, Core/Models/ReleaseStatus.swift, Features/Review/TrackPortalContent.swift, Features/Review/PlayerView.swift, Features/Browse/ProjectPortalContent.swift.
  • iOS: doc-35 phase 4b consumer — Shared / With-me feed goes live (completes doc-35) (2026-06-20, Xcode agent; BuildProject green, 0 diagnostics). SharedViewModel now subscribes to the user: room so the Shared tab updates in real-time when collaborators are added/removed or a team share is granted/revoked — no manual pull-to-refresh needed.
    • SocketUserEvent extended with 4 new cases: collaboratorAdded, collaboratorRemoved, teamShared, teamUnshared — matching the thin { resourceType, resourceId, [teamId,] actorId } payloads emitted by collaboration.service / team.service (phase 4b). Parsed in SocketIOManager. parseSocketEvent under a new // MARK: User room — sharing events block.
    • SharedViewModel.setupSocket()connect() + observeUser handler; any of the 4 sharing events triggers reloadAll() (parallel reload of both load() + loadByMe()). Own-echo suppressed via actorId == currentUser.id. onReconnectreloadAll() to catch any missed events after a drop. teardownSocket() clears the handler + reconnect slot on view disappear.
    • SharedView wires the calls: setupSocket() after initial load inside .task; .onDisappear calls teardownSocket(). Matches the pattern established by ArtistsHomeView in phase 5.
    • ArtistsHomeViewModel.handleUserEvent updated to handle the 4 new enum cases (break) so it stays exhaustive.
    • doc-35 is now fully complete — all backend emit (phases 1–4b) and all iOS consumers (phases 3, 5, 4b consumer) are shipped. Files: Core/SocketIOManager.swift, Features/Shared/SharedView.swift, Features/Browse/ArtistsHomeView.swift.

Fixed

  • iOS: AVAudioSession main-thread hang-risk warnings (PlayerView) (2026-06-19, Claude Code; xcodebuild BUILD SUCCEEDED). Xcode flagged PlayerView.swift:139–140 (SessionCore.mm:631 / AVAudioSession_iOS.mm:975) — both PlayerViewModel inits configured the shared audio session (setCategory(.playback) + setActive(true)) synchronously on the main actor (@MainActor class), which Apple warns can hang the UI because setActive blocks on the audio server. Extracted configureAudioSession() that runs the category/activate calls in a Task.detached(.userInitiated) off the main thread; both inits now call it. .playback needn't be active before AVPlayer starts, so deferring activation is safe — background playback / lock-screen transport behavior unchanged. File: Features/Review/PlayerView.swift.

Added

  • iOS: doc-35 phase 5 — browse view-models go live (2026-06-20, Xcode agent; BuildProject green). ArtistsHomeViewModel, ArtistDetailViewModel, and ProjectTracksViewModel now subscribe to their respective Socket.IO rooms so open lists update in real-time without a manual pull-to-refresh.

    • SocketIOManager extended: artist room (join:artist/leave:artist), project room (join:project/leave:project), user-room observer (observeUser/stopObservingUser). Three new event enums — SocketArtistEvent, SocketProjectEvent, SocketUserEvent. On CONNECT ack, all joined rooms are re-joined (matching the existing track-room behaviour). 500ms dedup window (recentEventKeys) drops duplicate deliveries that arise because the backend emits project:* to both artist: + project: rooms and artist:* to both artist: + user: rooms.
    • ArtistsHomeViewModelsetupSocket() registers a userHandler (the user: room is auto-joined server-side on authentication); artist:updated → full reload; artist:deleted → in-place remove + empty-state flip; own-echo suppressed via actorId. teardownSocket() on view disappear (called when navigating forward), so child VMs get the onReconnect slot.
    • ArtistDetailViewModelsetupSocket() joins artist:{id} and registers an artistHandler; project:created/updated + artist:updated → full reload; project:deleted → in-place remove; artist:deleted → sets didDelete = true → triggers dismiss. Own-echo suppressed.
    • ProjectTracksViewModelsetupSocket() joins project:{id} and registers a projectHandler; track:created/updated → full reload; track:deleted → in-place remove. Own-echo suppressed.
    • View lifecycle: each view's .task calls setupSocket() after initial load(); .onDisappear calls teardownSocket(). onReconnect triggers a fresh load() on reconnect for each active VM.
    • Files: Core/SocketIOManager.swift · Features/Browse/ArtistsHomeView.swift · Features/Browse/ArtistDetailView.swift · Features/Browse/ProjectTracksView.swift.
  • Backend: doc-35 phase 4a — real-time emit on hierarchy CRUD (2026-06-19, Claude Code; nest build clean, jest 14/14 green, 0 lint errors, boot-verified — gateway init + all artist/project/track routes mapped, no DI errors). Extends the realtime layer beyond phase-2 (track comments + review/release) to the browse hierarchy, so open lists can go live: track CRUD → the parent project room, project CRUD → the artist (+ project) room, artist update/delete (incl. roster) → the artist (+ owner user) room.

    • Events: track:created/updated/deleted (project: room) · project:created/updated/deleted (artist: + project: rooms) · artist:updated/deleted (artist: + owner user: rooms). Thin payloads ({ ids…, actorId }) per the doc-35 convention; clients suppress their own echo via actorId.
    • Emitted from the controllers, not the services — the controller is the uniform choke point where the actor (@GetUser) and the operation result both exist (the services have multiple internal create paths and mostly lack the user). Status-change emits stay in the service (phase 2, tied to the notify logic). Each controller got a small idOf() (populated-doc | bare-ObjectId | string) + a private emit*Event helper; deletes capture the parent id before the soft-delete. Emits are best-effort — wrapped so a socket failure can never break the HTTP response.
    • DI: the three controllers inject the existing @Global EventsGateway (zero module plumbing, same as phase-2's track.service). artistSpace.controller.spec.ts gained an EventsGateway mock provider; suite stays green. softDelete routes on track/project/artist gained @GetUser() for the actorId.
    • Inert until the paired iOS slice: only PlayerViewModel joins a track: room today; the browse view-models (Artists grid, Project tracklist, Artist detail) don't yet join:project/join:artist or merge these events. Phase 4a is the backend-contract-first half; the iOS join/merge is queued. Phase 4b (collaborator/team sharing events — per-recipient user: fan-out) is the remaining backend emit. Files: track/track.controller.ts, project/project.controller.ts, artistSpace/artistSpace.controller.ts (+ .spec.ts).
  • iOS: device-aware cover variant selection (doc-34, completes the loop) (2026-06-19, Claude Code; xcodebuild BUILD SUCCEEDED). The backend has served right-sized cover variants since 2026-06-14 (+ the 300² thumbnail2x tier added earlier today), but the client picked variants by role, not by rendered pixels — so the 140pt artist circle on the Home/Roster grid was fed the 150² thumb (≈420px on @3x → upscaled, blurry: the owner's exact complaint), and the Shared tab fed the full original into the same 140pt tiles (crisp but slow). Now selection is driven by points × displayScale.

    • ImageVariantSet (new Core/Models/ImageVariantSet.swift) — a Sendable value type holding thumb/thumb2x/medium/original URLs with url(forTargetPoints:scale:): the smallest available tier whose pixel size ≥ target, else the largest. Absent tiers are skipped, so a pre-doc-34 image (original only) always resolves.
    • ArtworkHero gained variants: + @Environment(\.displayScale); when variants are set it resolves the right URL from its own size (the component knows its frame, per the doc). Backward-compatible — imageURL: callers unaffected.
    • Models ArtistSpace / ProjectSummary / ProjectDetail decode the new imageThumbnail2x and expose imageVariants. (Track/File covers already render at correctly-sized tiers — 44pt rows use thumb, 180pt heroes use medium — so the 2x field there is a noted, non-urgent follow-up.)
    • Call sites: Home artist tiles (ArtistsHomeView.ArtistTile, incl. the long-press preview), Artist detail hero + project grid cards (ArtistDetailView: cards 150²→medium), and all four Shared tiles (SharedView inbound + outbound: original→variants/medium). Net: a 140pt avatar pulls thumb2x on @2x / medium on @3x instead of upscaling the 150² thumb, and grid cards stop downloading full originals. Files: Core/Models/ImageVariantSet.swift (new), Core/Models/ArtistSpace.swift, Core/Models/Project.swift, DesignSystem/Components/ArtworkHero.swift, Features/Browse/ArtistsHomeView.swift, Features/Browse/ArtistDetailView.swift, Features/Shared/SharedView.swift.
  • iOS: three owner-raised quick fixes (2026-06-19, Xcode agent; BuildProject green).

    • Roster picker in Artist PortalArtistDetailView now fetches GET /artist/rosters in parallel with its existing artist + projects calls and passes existingRosters into ArtistPortalContent. The existing roster picker (picker section above the create-new fields) now works from the Artist detail page, not only from the Home long-press. Wire type RosterDTO is file-level nonisolated to satisfy Swift 6 Sendable constraints; mapped to the existing RosterOption after fetch.
    • Short share URLsCollabLink gains shortId: String?; PortalShareSection.shareURL(for:) now emits {webOrigin}/s/{link.shortId ?? link.id}, using the 6-char code when the backend has assigned one and falling back to the full _id for older links.
    • Long-press halo square-mask fixArtistsHomeView.artistLink contextMenu migrated from the plain .contextMenu { ... } form to .contextMenu(menuItems:preview:). The explicit preview: renders a clean ArtistTile in a padded snBackground frame, so iOS snapshots that bounded view instead of clipping the circular halo glow to a square on long-press. Files: ArtistDetailView.swift, SharedItems.swift, PortalSharedSections.swift, ArtistsHomeView.swift.
  • Backend: 300² thumbnail2x image variant for high-density tiles (doc-34) (2026-06-19, Claude Code; nest build clean, jest 14/14 green, 0 new lint errors). Closes the owner-raised 2026-06-19 doc-34 gap: Roster/artist tiles looked low-res on @3x / iPad because the only sub-hero variant was the 150² thumb, which upscales on a 60pt avatar (≈180px drawn) while the next tier up — the 600px medium — wastes payload. Added a third cover-cropped WebP variant 300² across the whole pipeline, so the client ladder becomes thumb 150 → thumb2x 300 → medium 600 → original.

    • Pipeline: image.service.ts emits thumbnail2x (300² cover-crop, EXIF-rotated like the 150²); blob-storage.service.ts uploads <key>-thumb2x and returns thumbnail2xUrl; storage.service.ts propagates it.
    • Persisted + auto-signed: File.thumbnail2xUrl? and ArtistSpace/Project.imageThumbnail2x? (additive, back-compat; absent on pre-existing images). Both names sign for free via signUrlsInObject (thumbnail2xurl contains "url"; imagethumbnail2x contains "image") — no interceptor change.
    • Wired into every cover path: track create + createNewFile (new cover version), artist create/ update, project create/update — each now also persists the 300² URL on the File and mirrors it onto the entity.
    • Backfill extended: scripts/backfill-image-variants.ts now re-selects images missing thumbnail2xUrl (so early-doc-34 covers that only have thumb/medium get upgraded — variant regen is idempotent) and writes the 300² to File + entity. Still dry-run-gated (APPLY=true).
    • Spec fix: artistSpace.controller.spec.ts had two update assertions that were already stale since the 2026-06-14 doc-34 commit (exact-matched { image } while the controller adds imageThumbnail/imageMedium); updated the mock + both assertions to the real variant shape — suite is green again.
    • iOS follow-up (the actual user-facing fix): the client variant selection helper — targetPx = points × UIScreen.scale, pick thumb ≤150 · thumb2x ≤300 · medium ≤600 · original — + durable disk cache. Backend-contract-first per the routing rule; queued in .agent/next-brief.md. Reaches TestFlight backend on the next container redeploy. Files: image/image.service.ts, azure/blob-storage.service.ts, storage/storage.service.ts, file/models/file.schema.ts, file/models/file.dto.ts, artistSpace/models/artistSpace.schema.ts, artistSpace/models/artistSpace.dto.ts, artistSpace/artistSpace.service.ts, artistSpace/artistSpace.controller.ts (+ .spec.ts), project/models/project.schema.ts, project/models/project.dto.ts, project/project.service.ts, project/project.controller.ts, track/track.service.ts, scripts/backfill-image-variants.ts.
  • Backend: short share-link codes + GET /artist/rosters aggregate (2026-06-19, Claude Code; nest build clean, 0 new lint errors, both verified against real data). Two owner-raised concerns (2026-06-19).

    • Short share codes (doc-26): the Link entity had no short code — every /s/{id} used the 24-char ObjectId. Added a unique+sparse shortId (6-char generateShortId(), the same nanoid used by artist/project/track) set on share-link creation (createShareResourceLink + createShareLink, via a collision-retrying generateUniqueShortId()). findLinkById now resolves by shortId OR _id (Types.ObjectId.isValid branch), and addUserToLink's guard was relaxed accordingly — so new /s/xr7kq2 links resolve and legacy /s/{_id} links keep working. Verified: a 6-char code is not a valid ObjectId and resolves by both paths. /s/ confirmed = the universal share route (ENTITY_PREFIX.SHARE='s'; a/p/t are the per-entity deep-link prefixes) — not artist-specific; kept as-is. Follow-ups: iOS should emit link.shortId in the share URL (one-liner); existing links keep their _id URL (re-sharing reuses them) — a backfill / lazy-assign is the optional next step.
    • GET /artist/rosters: distinct {name, color} rosters across the caller's artists (owned · collaborator · shared), deduped + sorted (ArtistSpaceService.getDistinctRosters). Declared before :id so the static path wins. Unblocks the iOS Artist-Portal roster picker (it can fetch existing rosters in the single-artist context where the full list isn't loaded). Verified: returns the user's 3 rosters, route not shadowed. Files: link/models/link.schema.ts, link.service.ts, collaboration.service.ts, artistSpace.controller.ts, artistSpace.service.ts.
  • iOS: Halo transition from parent's identity color (2026-06-19, Xcode agent; BuildProject green). Fixes owner-raised doc-22 §6 continuity gap: when a Track has its own cover but its Project does not, the player halo previously crossfaded from the track's own identity default (a different color than the project the user just came from). Now the initial halo is seeded from the project's identity color (Theme.accent(forKey: projectId)) so the transition reads as continuous browsing context.

    • TrackRoute gained projectId: String? (nil for Recents / Shared entry points with no single project context; backward-compatible via default nil).
    • ProjectTracksView passes viewModel.projectId in both NavigationLink and create-track callback TrackRoute constructions.
    • PlayerViewModel.init(trackId:) accepts projectId: String?; seeds artworkAccent from Theme.accent(forKey: pid) when projectThumbImageURL is nil (no project cover cached), so the track cover animates FROM the project's ambient color rather than the system default. Files: ArtistsHomeView.swift, ProjectTracksView.swift, PlayerView.swift.
  • Backend: real-time emit on track comments + review/release status (2026-06-19, Claude Code; nest build clean, 0 new lint errors, boot-verified — gateway inits + DI resolves, no cycles). doc-35 phase 2, after the join:* access gate. Domain services now broadcast typed events to the (access-gated) Socket.IO rooms so live sessions update without a manual refresh — the owner's "two users on the same version, comments appear at publish" scenario.

    • CommentService injects EventsGateway and emits to track:${trackId}: comment:created ({trackId, actorId, comment} — full populated payload, safe since the room is access-gated → instant insert, no refetch), comment:reply ({trackId, commentId, response}), comment:updated ({trackId, commentId} — thin; covers resolve/edit/like), comment:deleted ({trackId, commentId}).
    • TrackService injects EventsGateway and emits track:releaseStatusChanged ({trackId, dimension, status, actorId}) and file:reviewStatusChanged ({trackId, fileId, status, actorId}) to both track:${id} and project:${projectId} (the project room drives tracklist status badges). actorId lets clients ignore their own echo.
    • Emits are post-mutation (after the DB write) and a no-op when the room is empty, so they can't affect the REST result; no module changes needed (WebsocketModule is @Global).
    • Remaining (doc-35 phase 3): the iOS Socket.IO client — connect post-auth, join/leave per visible screen, merge events, catch-up fetch on reconnect. Live socket delivery gets verified there (or via a two-account run). Files: comment.service.ts, track.service.ts.
  • iOS: Socket.IO live-sync client — doc-35 phase 3 (2026-06-19, Xcode agent; BuildProject green). Comments and status pills now update live without a manual refresh. Backend emit was already live (phase 2); this slice wires the iOS receive side.

    • SocketIOManager (Core/SocketIOManager.swift): @MainActor @Observable singleton; URLSessionWebSocketTask-based Socket.IO v4 client (EIO=4, WebSocket transport; no third-party SPM dependency). Speaks the Engine.IO framing directly: Engine.IO PING→PONG (2/3), namespace CONNECT ack (40), EVENT (42[name,payload]). Auth: JWT in query string (?token=<jwt>) + Authorization: Bearer header on the upgrade request. Auto-reconnects after a 3-second back-off; re-joins all rooms on namespace CONNECT ack. onReconnect callback fires only after the first successful connection so the initial bootstrap() fetch isn't duplicated.
    • PlayerViewModel (PlayerView.swift) — three new methods:
      • setupSocket() (called from bootstrap(), idempotent): connect()joinTrack() → registers a per-trackId handler → wires onReconnect to loadCommentsAndSections().
      • teardownSocket() (called from PlayerView.onDisappear): leaveTrack() → deregisters handler → clears onReconnect.
      • handleSocketEvent(_:): applies each SocketTrackEvent to in-memory state — comment:created (appends to comments if fileId matches current file, skips own echo), comment:deleted (removes by id), comment:reply / comment:updated (refetch — thin events), track:releaseStatusChanged (merges into detail.releaseStatus in place, skips own echo), file:reviewStatusChanged (refetches detail if fileId == current file, skips own echo). Files: Core/SocketIOManager.swift (new), Features/Review/PlayerView.swift.
  • iOS: Roster existing-picker in RosterEditorSheet (2026-06-19, Xcode agent; BuildProject green). Fixes owner-reported gap: users can now assign an artist to an already-existing roster without retyping the exact name and color. When the home view has ≥1 roster, the sheet opens with an "EXISTING ROSTERS" list above the create-new fields; tapping a row immediately PUTs the artist into that roster and dismisses. The artist's current roster is marked with a checkmark. An "or create new" divider separates the picker from the name/color fields; Remove still clears the roster. No new endpoint — roster options are derived from ArtistsHomeViewModel.distinctRosters (already-loaded artists).

    • RosterOption struct added (ArtistsHomeView.swift) — carries name + color for the picker.
    • ArtistsHomeViewModel.distinctRosters computed property — deduplicates + sorts by name.
    • RosterEditorSheet — new existingRosters: [RosterOption] param, existingRostersSection view, orDivider, quickAssign() function, assigningRosterName state for per-row loading indicator.
    • ArtistPortalContent — forwarded existingRosters param so the portal's roster editor also shows the picker when the caller provides the list. Files: ArtistsHomeView.swift, ArtistPortalContent.swift.
  • iOS: #Tags lens live (2026-06-18, Xcode agent; BuildProject green). Replaces the Wave-3 #Tags · soon stub with a working lens. GET /search/tags aggregate populates the chip; selecting a tag filters Artists (Home), Project tracklist, and Shared (With me) client-side by node.tags. Combinable with Role / Status / Roster facets; chip stays hidden when the aggregate is empty (solo/empty invariant). SavedView now persists tagFilter (Codable, nil-safe for existing saves).

    • Models: tags: [String]? added permissively to ArtistSpace, ProjectSummary, ProjectDetail, Track — absent on older payloads decodes to nil; server strips private tags before delivery.
    • LensBar: LensBarConfig.tagOptions: [String] (replaces showTagsStub: Bool); LensSelection
      • SavedView carry tagFilter: String?; hasAnyOptions updated.
    • ViewModels: availableTags fetched concurrently alongside the main load in ArtistsHomeViewModel, SharedViewModel, and ProjectTracksViewModel; tag-filter applied in each filtered* / artists(applyingLens:) function. Tags fetch failure is non-fatal (bar stays hidden). Files: LensBar.swift, ArtistSpace.swift, Project.swift, Track.swift, ArtistsHomeView.swift, SharedView.swift, ProjectTracksView.swift.
  • Backend: #Tags store (node tags + opt-in sharing + lens aggregate) (2026-06-18, Claude Code; nest build clean, 0 new lint warnings, boot-verified — /search/tags mapped, DI resolved). Builds the backend the doc-26 Wave-3 #Tags lens was stubbed against. Honors the locked decision (owner 2026-06-14, doc-33 §Decisions): tags are per-resource, owner-authored, private by default, propagating to a recipient only via an opt-in shareTags flag. Distinct from the recipient-side my-tag.

    • Schema: tags: string[] (default []) + shareTags: boolean (default false) on ArtistSpace, Project, Track.
    • Write (reuse existing PUT): tags? / shareTags? added to ArtistSpaceDTO / ProjectDTO / TrackDTO; PUT /artist|project|track/:id persist them unchanged. Each service.update runs a new normalizeTags() util (src/shared/utils/normalize-tags.util.ts) — trim · lower-case · de-dupe · drop empties · cap ≤25 tags / ≤40 chars.
    • Privacy (read): new global StripPrivateTagsInterceptor (src/shared/interceptors/strip-private-tags.interceptor.ts), registered before SignUrlsInterceptor so it runs last on the response (reverse order) over plain objects. It removes tags from any node the caller doesn't own unless shareTags === true. No-op for owners / solo users; only affects cross-account reads.
    • Lens aggregate: GET /search/tags → distinct tags the caller can see (owned always + shared where shareTags === true), enforced at the query level in SearchService.tags() (3 models injected into SearchModule). Returns a sorted string[].
    • Out of scope (flagged): Comment.tags[] (doc-33 notes — Portals/notes phase); per-recipient tag sharing (v1 is resource-level all-or-nothing); strict owner-only tag editing (v1 = editor+ via PUT).
    • Lights up the disabled #Tags lens stub once the iOS slice wires to GET /search/tags + node.tags. Mark doc-26 §Layer 2 #Tags + doc-33 §Backend map: node tags store shipped. Files: {artistSpace,project,track}/models/*.schema.ts + *.dto.ts, {artistSpace,project}.service.ts, track.service.ts, search.{service,controller,module}.ts, shared/utils/normalize-tags.util.ts, shared/interceptors/strip-private-tags.interceptor.ts, app.module.ts.
  • iOS: Graceful network error handling (2026-06-18, Xcode agent; BuildProject green). Eliminates the infinite spinner. Every load surface now resolves to a visible state — loaded data, a friendly empty state, or a calm error with a Retry button. No backend change.

    • Root cause fixed in APIClient: removed waitsForConnectivity = true (blocked indefinitely on unreachable host); added timeoutIntervalForRequest = 20 so every call times out in ≤20 s.
    • APIError.notConnected (new case): URLError codes .notConnectedToInternet, .cannotConnectToHost, .timedOut, .networkConnectionLost, .cannotFindHost, .dnsLookupFailed map to .notConnected so the UI can distinguish "can't reach server" from "server returned an error". APIError.noToken now maps to .unauthorized in send() so missing-token failures route to sign-in rather than a generic error.
    • LoadState<T> (new — Core/LoadState.swift): .idle | .loading | .loaded(T) | .empty | .failed(APIError). Every fetch surface ends in one of these — never an endless spinner.
    • SnErrorView + SnEmptyView (new — DesignSystem/Components/ErrorStateView.swift): SnErrorView shows icon + calm title/message + Retry button; distinguishes "Can't reach Sonnance" (wifi.slash icon, connection copy) from "Something went wrong" (server/decoding copy). SnEmptyView shows icon + friendly copy with no action.
    • Applied to all browse + shared surfaces: ArtistsHomeViewModel, RecentsViewModel, SharedViewModel (both "With me" + "By me" segments), TeamsViewModel — all migrated from isLoading: Bool + errorMessage: String? to loadState: LoadState<Void>. Views switch on .loadState and render SnErrorView / SnEmptyView as appropriate.
    • DoD met: server unreachable → error state with Retry within the timeout; Retry recovers once reachable; empty states render for no-data; 401-with-failed-refresh routes to sign-in.
  • iOS doc-26 Wave 4: Teams UI (2026-06-18, Xcode agent; BuildProject green). Surfaces the backend Wave 4 team endpoints in native SwiftUI. Invariant held: lenses filter, roles gate — a team share only ever fans out to standard per-resource collaborator roles; no read-path changes.

    • TeamModels.swift (Core/Models/): Team, TeamMember, TeamShare + request types; TeamMember.userId uses the established dual-shape decode (plain string or populated User object).
    • TeamsView.swift + TeamDetailView.swift (Features/Teams/): Teams list embedded as a new "Teams" segment in the Shared tab. Pending invites shown separately with inline accept/decline buttons; active teams (owned + accepted member) tap through to TeamDetailView. TeamDetailView (sheet): members list with pending/accepted status, invite-by-email (owner/admin), leave team, disband (owner only), rename (owner/admin), view + revoke shares. All 13 backend endpoints wired.
    • PortalTeamShareSection + TeamSharePickerSheet (added to PortalSharedSections.swift): reusable portal section showing which of the user's teams have access to a given resource, with role badge + revoke (owner/admin); "Share to a team…" opens TeamSharePickerSheet (team picker + role selector). Section is self-loading (parallel GET /team/:id/shares per team) and hides when empty for non-admins. Added to Artist, Project, and Track Portals.
    • PlayerView gains @Environment(AuthManager.self) to forward currentUserId into TrackPortalContent, enabling the track-level team share section for the signed-in user.
    • Scoped out (per brief): break-inheritance, file/version-level team share, provenance-precise revoke, full "shared via Team Y" badge. Multi-account verification (invite→accept→share→member-sees) needs a two-account test run — flagged as the known QA gap.
  • Backend doc-26 Wave 4: Teams / Groups as share targets (2026-06-17, Claude Code; nest build clean, lint 0 errors, boot-verified — all 13 /team routes mapped). The B2B share primitive: a reusable Team (a named set of people) so an owner can share an artist/project/track to a whole group at once, across ownership boundaries. Invariant held: lenses filter, roles gate — a team is convenience over the existing collaborator gate; a share only ever lands as standard per-resource collaborator roles, and only the sharer's owned resources can be shared in.

    • New team module (src/modules/team/): Team schema (name, image, owner, embedded members[] = { userId, teamRole: admin|member, status: pending|accepted, invitedBy }; creator seeded as an accepted admin) + TeamShare ledger ({ team, resourceType, resourceId, role, sharedBy }, unique per (team, resource)) that powers reconcile-on-join and un-share.
    • Grant model = fan-out + reconcile on join (owner-decided). Sharing fans the grant out to every accepted member via the existing collaborator cascade; a late joiner is back-granted every TeamShare on accept; leaving / removal / un-share revokes. No permission/read path changed.
    • Governance = owner + admins (owner-decided): owner can disband; owner+admins invite/remove and un-share; any accepted member can share their own resources in. Membership is by consent — invitees are pending until they accept (new pattern — direct collaborator invites have no accept).
    • 13 endpoints (/team): CRUD (POST/GET/GET :id/PUT :id/DELETE :id), membership (POST :id/invite · :id/accept · :id/decline · DELETE :id/member/:userId), sharing (POST :id/share · GET :id/shares · DELETE :id/share/:resourceType/:resourceId).
    • Reused, not rebuilt: added id-based grantCollaboratorCascade / revokeCollaboratorCascade (+ getResourceMeta ownership lookup) to CollaborationService — the grant mirrors the existing invite $addToSet cascade but is idempotent and never-downgrades (push-if-absent + upgrade-if- strictly-lower); revoke is $pull-based, non-throwing, notification-free. CollaborationService is now exported from CollaborationModule; TeamModule imports it (no cycle). Invites reuse userService.createTemporaryUser; notifications add INVITATION_TO_TEAM / SHARED_TO_TEAM / REMOVED_FROM_TEAM; email adds an EmailContext.TEAM branch (best-effort, falls back to the space template until a TEAM_INVITATION_TEMPLATE_ID is provisioned).
    • Scoped out (flagged, not built): SharePoint-style break-inheritance (exclude-a-child) — an orthogonal, platform-wide permissions feature; granularity for now = the level you share at. Also out: fans/consumer listening (B2B-first, doc-31), File/version-level team share, iOS UI (next slice), "shared via Team Y" badge rendering, and provenance-precise revoke (v1 revoke removes the member from each TeamShare resource; if they also had independent direct access it's removed too — a grantedVia marker is the noted follow-up).
    • Mark doc-26 §Wave 4 shipped (backend). Files: team/{team.controller,team.service,team.module}.ts, team/models/{team.schema,teamShare.schema, team.dto}.ts, collaboration.{service,module}.ts, notification.dto.ts, email/{email.service, models/email.types}.ts, app.module.ts. Reaches TestFlight backend on the next container redeploy.
  • iOS doc-26 Wave 3: lenses & saved views (2026-06-17, Xcode agent; BuildProject green). "One graph, many lenses" backbone — a calm, collapsible LensBar on the three browse surfaces (Home/Artists, Project tracklist, Shared). All filters are client-side over already-loaded data; no backend change. Role invariant preserved: lenses filter, roles gate — no lens surfaces a resource the user's role wouldn't already grant.

    • LensBar component (DesignSystem/Components/LensBar.swift): horizontal chip row with Menu-driven facet pickers (Role, Status, Roster, #Tags stub). Active chips fill with snAccent; inactive chips are outlined. Trailing "Views" bookmark chip opens SavedViewsSheet.
    • LensManager (singleton, @Observable): persists saved views to UserDefaults; seeded with two defaults ("My Work" / "Awaiting Approval"). Create, rename via save-alert, delete via swipe.
    • SavedViewsSheet: lists saved SavedView items (name + SF Symbol + color), load any into the active lens, save the current active lens combination as a new named view.
    • LensSelection + LensBarConfig: role / status / roster facets; hasAnyOptions drives the solo-user invariant — bar hidden when nothing meaningful to filter (solo user, one artist, uniform status).
    • ArtistsHomeView: role lens (Mine / With me) + roster lens; both re-group the roster-sectioned grid. Bar hidden when only one artist or all share the same ownership.
    • ProjectTracksView: status lens over the track list; shown only when 2+ distinct effective statuses exist. Animated list update (.easeInOut 200ms).
    • SharedView (With me segment): role + roster lenses; #Tags rendered as a disabled "soon" stub (awaits backend tags store). Lens resets on segment switch.
    • Fixed: status filter chips showed action labels ("Approve" / "Request Changes") instead of descriptive labels. Added TrackReleaseStatus.filterLabel ("Approved" / "Not Approved") used by ProjectTracksViewModel.lensConfig; label preserved for the approve bar.
    • Motion principle: filtered content fades with .easeInOut(0.2s) on lensSelection change (discrete state, doc 22 §4). Chip active/inactive transitions use .easeInOut(0.15s).
    • #Tags lens deferred (backend tags store not built; stub chip visible-but-disabled on Shared).
    • Mark doc-26 §Build plan Wave 3 ✅. Files: LensBar.swift, ReleaseStatus.swift, ArtistsHomeView.swift, ProjectTracksView.swift, SharedView.swift. ⚠️ iOS changes uncommitted.

Fixed

  • Security: WebSocket room joins are now access-gated (2026-06-19, Claude Code; nest build clean, boot-verified — gateway inits + DI resolves; decision logic verified against real data). The first step of doc-35 real-time sync: the EventsGateway join:track / join:project / join:artist handlers previously called client.join(...) with no permission check, so any authenticated socket could join any resource's room. Latent today (nothing emits to rooms yet) but a data-leak side channel the moment emit wiring lands — so gated first. Added EventsGateway.canAccess(userId, type, id) mirroring the REST collaboration gate (owner · collaborator — denormalized down the hierarchy by the invite cascade · sharedWith link recipient); each join:* now awaits it and returns { success: false, error: 'forbidden' } (no join) when denied. WebsocketModule gains MongooseModule.forFeature([Track, Project, ArtistSpace]) for the lookup. Verified on real data: owner → allowed, stranger / bad-id / no-user → denied. Emit-on-mutation + iOS client subscribe/merge remain the next doc-35 phases. Files: websocket/events.gateway.ts, websocket/websocket.module.ts.

  • iOS DEBUG could not reach the API on a physical device (2026-06-18, Claude Code; xcodebuildBUILD SUCCEEDED). Symptom: app stuck on an infinite loading spinner; Xcode console showed Socket SO_ERROR [61: Connection refused] to 127.0.0.1:8080 / ::1:8080. Root cause:APIConfig.baseURL used http://localhost:8080 in DEBUG — fine on the Simulator (localhost = the Mac, where the local server runs) but unreachable from a physical device (localhost = the device itself). The deployed API (https://api.sonnance.ff8.dev) was healthy throughout (/health 200). Fix (two parts): (a) zero-config device defaultAPIConfig now resolves DEBUG via #if DEBUG && targetEnvironment(simulator): Simulator-DEBUG → localhost:8080 (local dev), device-DEBUG → deployed API (a device can't reach the Mac's localhost), RELEASE → deployed. So on-device debug builds work without any scheme setup. (b) a runtime env override on top — SONNANCE_API_BASE / SONNANCE_WEB_ORIGIN (Xcode scheme → Run → Environment Variables) win when set, for pointing a device at a LAN/local server. (An env-var-only first cut was insufficient — unset scheme vars fell back to localhost — hence the compile-time device default.) Also cleared the nonisolated(unsafe) "unnecessary for Sendable type" warnings in APIConfig + KeychainStore by switching to the safe nonisolated (plain removal would have re-isolated the constants to MainActor under Xcode 26 default-isolation and broken the actor accessors). xcodebuild BUILD SUCCEEDED, 0 warnings in both files. Files: ctrl-audio-ios/.../Core/APIConfig.swift, .../Core/KeychainStore.swift. Action: rebuild & run on the device — no env var needed now.

    • Note: this resolves connectivity config only; the app still lacks graceful error/timeout/retry UI, so any future unreachable-server case still shows an infinite spinner. Surfacing that (loading / error / empty states + request timeout + "can't reach server" message) is a recommended follow-up iOS slice (owner's original ask) — not yet built.
  • iOS track covers in Recents + Shared track rows (2026-06-14, Xcode agent; BuildProject green). RecentsView and SharedView built TrackRow without passing imageURL, so track covers were never shown in those lists even though the Track model already carries image: [MediaFile] + coverThumbURL (landed with doc-34 variants). Fix: thread track.coverThumbURL into every TrackRow call in both views — RecentsView.row(for:), SharedView.sharedTrackRow(_:) (inbound), and SharedView.byMeTrackRow(_:) (outbound). Cascade is intact: track cover → gradient fallback when coverThumbURL is nil. Files: RecentsView.swift, SharedView.swift. ⚠️ iOS changes uncommitted.

  • iOS image-picker controls on top + project cover refresh (2026-06-14, Claude Code — implemented directly + xcodebuild verified, exit 0). Two owner reports. (1) Picker confirm controls hidden behind a large image. ImagePickerButton.confirmSheet was a ScrollView with the preview (.fit, unbounded height) above text buttons → a tall image pushed "Use this image" / "Choose again" off-screen. Reworked into a VStack with a fixed top icon bararrow.uturn.backward (choose again, left) + checkmark.circle.fill (use, right), always visible — and the preview now fills the remaining space (maxHeight: .infinity + .fit) so it can never overflow the controls. Simpler (icon-only) and on-brand. (2) Project cover didn't update after a change until re-navigating. The ProjectTracksView hero binds to projectImageURL (@State, seeded from the route at init); the Portal's cover upload called onRefetch → viewModel.load(), but load() only refreshes tracksprojectImageURL was never updated. Fix: onRefetch now also calls fetchEditableProject() and updates projectImageURL / projectThumbImageURL from the fresh project. The blob path is stable but the re-signed SAS URL differs each fetch, so AsyncImage re-downloads the new bytes and .task(id: projectImageURL) re-samples the halo — hero + glow update in place. Files: ImagePickerButton.swift, ProjectTracksView.swift. ⚠️ iOS changes uncommitted.

Changed

  • iOS UI polish: toolbar cleanup + ProjectCard square crop + image-picker full-screen confirm (2026-06-14, Xcode agent; BuildProject green). Three polish items from doc-24 §Toolbar & image UX. (1) Overflow menus removed — delete lives in the Portal. ArtistDetailView and ProjectTracksView each had ⓘ · + · ••• (overflow) toolbars. "Rename" was already the Portal's inline "Edit"; the only unique overflow action was Delete. The ••• Menu was removed from both toolbars → clean two-button layout (ⓘ + +). Delete was added to ArtistPortalContent and ProjectPortalContent as a destructive button at the bottom of the Portal (owner/admin only, gated by onDelete != nil), mirroring the existing pattern in TrackPortalContent. onDelete: () async -> Bool callback passed from each parent; Portal calls dismiss() on success; parent's existing onChange(of: viewModel.didDelete) pops nav. ProjectPortalContent also gains onNameChange: (String) -> Void so a Portal-side rename updates the localProjectName in ProjectTracksView immediately (previously only the overflow EditProjectSheet did this). Removed: showEditArtistSheet, showDeleteConfirm/showDeleteProjectConfirm states + the EditArtistSheet sheet + both confirmationDialog modifiers from the parent views. (2) ProjectCard now crops portrait covers to a square. Changed from ZStack { … }.aspectRatio(1,.fit) to RoundedRectangle.aspectRatio(1,.fit).overlay { AsyncImage }.clipShape(…) — the shape owns the 1:1 frame; the image is an overlay that inherits that size and scaledToFill crops correctly without the image driving card height. Mirrors the ArtworkHero pattern. (3) Image-picker confirm sheet presents at .large detent. Changed .presentationDetents([.medium, .large])[.large] in ImagePickerButton.confirmSheet so "Use this image" / "Choose again" controls are visible without scrolling. Files: ArtistDetailView.swift, ArtistPortalContent.swift, ProjectTracksView.swift, ProjectPortalContent.swift, ProjectCard.swift, ImagePickerButton.swift.

Fixed

  • iOS halo: artist-page first-frame color (final nav-flash fix) (2026-06-14, Claude Code — implemented directly + xcodebuild verified, exit 0). The prior nav-flash fix added a synchronous cachedColor read in haloAccent, but it couldn't help the artist detail page: on the first frame viewModel.artist is still nil (it loads async), so there's no thumbImageURL to look up → haloAccent fell through to the identity accent until the artist loaded = the flash the owner still saw. Fix (mirrors the working TrackRoute pattern): ArtistRoute now carries imageThumbnail; the navigating tile passes artist.thumbImageURL (home grid + both Shared feeds); ArtistDetailView.init seeds artworkAccent synchronously from AdaptiveColor.cachedColor(for:) of that URL — so the cover's vibrant halo paints on the first frame (the tile already sampled+cached it). Files: ArtistsHomeView.swift (ArtistRoute + tile link + destination), SharedView.swift (2 links + destination), ArtistDetailView.swift (init seed). Back-compat (imageThumbnail defaults nil; the create-flow push is unchanged). General principle (doc 22 §6): a detail screen's own data is async, so the cover URL must arrive via the route and seed the accent at init — a sync read in the computed property alone isn't enough. ⚠️ iOS changes uncommitted (3 files).

  • iOS halo: kill nav flash + track→album cover cascade (2026-06-14, Xcode agent; BuildProject green). Three halo/cover bugs — pure iOS, no backend change. (1) Nav flash eliminated in ArtistDetailView + ProjectTracksView. Root cause: both views blanked artworkAccent = nil at the start of their .task, then re-resolved asynchronously — even though the color was already in AdaptiveColor.colorCache from the previous screen's tile. Fix: removed the nil-blank; added a synchronous cachedColor check to haloAccent so the first frame paints the correct vibrant color without any intermediate identity flash. (2) Player halo now resolves on the first frame. Root cause: PlayerViewModel.refreshArtworkAccent used cover.blobUrl as the cache key — a SAS-signed URL that rotates per-request and never matches the thumb URL cached by ArtistTile / ProjectTracksView. Fix: switched the sampling URL to cover.thumbnailUrl ?? cover.blobUrl (matches the cache key other screens use). PlayerView.haloAccent now also checks the sync cache for the track thumb and project thumb before falling back to identity. (3) Track→album cover cascade in the player. PlayerView opened from ProjectTracksView now receives the project cover URLs via a new TrackRoute navigation value (replaces the plain String push from ProjectTracksView). PlayerViewModel.init(trackId:projectThumbImageURL:projectImageURL:) seeds artworkAccent synchronously from the project thumb cache on init. refreshArtworkAccent cascades: track thumb → project thumb → nil. emptyVersionsState artwork hero cascades: detail.coverMediumURL ?? viewModel.projectImageURL — a cover-less track shows the album art. Models: TrackDetail gains coverThumbURL and coverMediumURL computed props (mirror of Track's existing helpers). TrackRoute added to ArtistsHomeView.swift alongside ArtistRoute / ProjectRoute; ArtistsHomeView gains navigationDestination(for: TrackRoute.self). DoD: BuildProject green; navigating roster→artist→track shows vibrant halo on the first frame; a track with no cover but a project cover shows album art + album halo; coverless+albumless → identity.

  • iOS halo home-scoping + double-halo (2026-06-14, Xcode agent; BuildProject green). Two halo bugs on the Artists/Roster home grid — both pure iOS, no backend change. (1) Home screen no longer borrows a cover color. ArtistsHomeView previously used ScreenBackground(accent: Color.snAccent) at full 55% intensity, compositing a strong identity ring over every artist tile's vibrant per-tile glow → visible double halo in different colors. Fixed: home now uses intensity: 0.10 — a calm brand wash that doesn't compete with per-tile shadows. The haloAccent conditional property (which was already returning Color.snAccent) was removed; the home always shows the subtle wash. (2) ScreenBackground gains intensity parameter. DesignSystem/Surfaces/ScreenBackground.swift now accepts intensity: Double = 0.55 (default unchanged — all detail/player screens keep full-strength). The param is forwarded to AdaptiveGradient. Grid screens pass 0.10. (3) Double-halo audit. ArtworkHero gradient fill is clipped inside the shape and fully hidden by the artwork image — no extra visible layer. ArtistDetailView / ProjectTracksView / PlayerView are clean: their ScreenBackground and ArtworkHero.shadow use the same vibrant color, so they read as one coherent halo. The only double was home screen identity + tile vibrant, now resolved. DoD: BuildProject green; home has its own calm identity wash independent of any artist's cover; each tile's ArtworkHero.shadow is the sole per-artist halo; no double-halo anywhere.

Added

  • iOS doc-34: image variants + disk cache (2026-06-14, Xcode agent; BuildProject green). Consumes the backend's right-sized WebP variants on every cover, plus configures a 150 MB disk image cache — the primary fix for slow Home/cover loads. Models: MediaFile gains optional thumbnailUrl (150×150) and mediumUrl (600px); ArtistSpace gains imageThumbnail / imageMedium with thumbImageURL and mediumImageURL computed URL helpers (fallback chain: variant → original → nil); ProjectSummary and ProjectDetail gain the same fields + helpers; Track gains coverThumbURL and coverMediumURL via a shared private coverFile resolver. Size selection: tiles/rows → thumb (ArtistTile in home grid; TrackRow in project tracklist; ProjectCard in artist detail); heroes → medium (ArtistDetailView 180px circle; project hero in ProjectTracksView). Fallback to original when variant is absent (pre-doc-34 images). ProjectRoute carries both image (medium, for hero) and imageThumbnail (thumb, for card + track-row fallback). Halo color sampling switched to thumb URL in ArtistDetailView and ProjectTracksView — faster download, same 20×20 sample quality; a cache hit from the home-tile pass makes the color synchronous on navigation (no halo flash). Synchronous cachedColor check added before async vibrant call in both screens. Disk cache: Core/ImageCache.swift configures URLCache.shared to 20 MB memory + 150 MB disk at app launch (SonnanceApp.init), making AsyncImage and AdaptiveColor.vibrant's URLSession reads durable across sessions.

Added

  • iOS doc-26 Wave 3: lenses & saved views (2026-06-14, Xcode agent; BuildProject green). Closes the doc-26 "one graph, many lenses" navigation backbone (§Layer 2 + §Build plan Wave 3). All client-side — no backend change. Invariant maintained: lenses filter; roles gate. (1) LensBar design-system component (DesignSystem/Components/LensBar.swift): horizontal chip row with Menu-based facet pickers. Active chips filled snAccent; inactive outlined. Appears only when LensBarConfig.hasAnyOptions — the solo-user invariant: bar is invisible for solo users with one artist, no rosters, and no shares. Three facets: Role, Status, Roster. #Tags rendered as a disabled stub (pending backend tags store, deferred per brief). (2) LensManager (@Observable singleton): persists named saved-view combinations to UserDefaults as JSON (sn.savedViews.v1). Seeded with two defaults — "My Work" (role=owned) and "Awaiting Approval" (status=Under review). SavedViewsSheet lists all saved views with swipe-to-delete and a "Save current view…" action that fires an alert for the name. (3) Applied to three surfaces: ArtistsHomeView — Role (owned/shared) + Roster lenses, bar shown only when user has BOTH owned AND shared artists; filtering applies to flat and roster-grouped layouts. ProjectTracksView — Status lens (derived via Track.effectiveStatus), shown only when tracks carry 2+ distinct statuses; Track model gains optional releaseStatus: ReleaseStatus?
    • effectiveStatus: TrackReleaseStatus computed property (not approved > under review > approved

    no status). SharedView — Role + Roster lenses on the "With me" segment only; lens resets on segment switch; filtering applies to all three inbound sections (artists/projects/tracks). Saved-views sheet accessible from all three surfaces via the bookmark chip.

Fixed

  • iOS halo home-scoping + double-halo fix (2026-06-14, Xcode agent; BuildProject green). Two bugs re-opened by owner. (1) Home halo scoping: ArtistsHomeView no longer borrows its ScreenBackground halo from the first artist's cover. The home screen now has its own fixed brand-identity halo (Color.snAccent) that appears once artists load — neutral, brand-appropriate, and independent of any content. The per-artist glow moves down to the tile: ArtistTile gains @State private var tileAccent: Color? resolved via .task(id: imageURL) (cache hit → synchronous; miss → AdaptiveColor.vibrant(for:)), passed as tint to ArtworkHero. Result: each tile glows in its own cover's vibrant color; tiles without a cover fall back to the identity accent shadow. Home animation keyed to viewModel.artists.isEmpty (no longer URL). (2) Double halo eliminated: in all detail screens (ArtistDetailView, ProjectTracksView, PlayerView) the ArtworkHero.tint was always the identity accent, creating a second identity-colored shadow alongside the vibrant ScreenBackground gradient. Fix: pass artworkAccent ?? accent (or viewModel.artworkAccent ?? identityAccent for the player) as tint — the artwork shadow now matches the screen halo color exactly. When a cover is loaded the shadow is vibrant (one unified halo layer); when there is no cover it is identity (also one layer). Never both.

  • iOS halo polish — Artists/Roster home + kill load-time flash (2026-06-13, Xcode agent; BuildProject green). (1) ArtistsHomeView vibrant halo: the Home/Roster grid now has a ScreenBackground(accent:) halo keyed to the first artist that has a cover (haloAnchorURL). artworkAccent: Color? is computed via AdaptiveColor.vibrant(for:) in a .task(id: haloAnchorURL) — fires on initial load and on cover change. Identity accent when no artist has a cover; nil (no halo) while loading. All four browse levels (home / artist / project / track) now carry the halo. (2) No load-time flash on any halo host:AdaptiveColor gains a static var colorCache: [URL: Color]vibrant(for: URL) async checks it first (cache hit → synchronous return, no settle); cachedColor(for:) is the synchronous lookup for VM callers. All halo views (ArtistDetailView, ProjectTracksView, ArtistsHomeView) now use vibrant(for:) instead of inlining URLSession + vibrant(from:). PlayerViewModel.refreshArtworkAccent adds a synchronous cache check so re-visits are instant. (3) Animation keyed to cover ID, not color: .animation(value:) on all four screens is now keyed to the cover identifier (nonEmptyImage URL string / projectImageURL / haloAnchorURL / coverFile?.id) — the crossfade fires only when the cover changes, never on the async resolve of the same cover. haloAccent: Color? returns nil (not identity) while a real cover's color is in-flight — identity accent is now exclusively the no-cover state.

  • iOS cover/halo QA re-open — per-track cover + vibrant halo on all heroes (2026-06-13, Xcode agent; BuildProject green). Two findings re-opened by the owner after the prior QA slice. (1) Per-track cover in the tracklist: Track model (Core/Models/Track.swift) now decodes image: [MediaFile] and imageFinal: String? via a permissive init(from:) (empty-guards like TrackDetail), and exposes a coverURL: URL? computed property (prefers imageFinalisFinalVersion → last entry). ProjectTracksView passes track.coverURL ?? projectImageURL to each TrackRow — each row now shows its own cover; falls back to the project art when the track has none, then to the identity gradient. (2) Vibrant halo on artist and project heroes: ArtistDetailView and ProjectTracksView now derive artworkAccent: Color? from their hero image via AdaptiveColor.vibrant(from:) using .task(id:) (fires on initial load and whenever the image URL changes; cancels in-flight on change). haloAccent = artworkAccent ?? accent; passed to ScreenBackground(accent:) with an .animation(.easeInOut(duration: 0.8)) crossfade — matching the pattern PlayerView already uses. All four levels (artist / project / track / version) now read the "pulled from the art" halo consistently; cover-less nodes keep the identity-accent gradient.

  • iOS image & identity QA (2026-06-13, Xcode agent; BuildProject green). Three TestFlight regressions. (1) Photos source regression in ImagePickerButton: PhotosPicker embedded as a Menu child doesn't present (SwiftUI dismisses the menu before the picker binding activates). Fix: Photos is now driven by a @State var showPhotos flag toggled by a plain Button inside the menu; the picker is attached as a .photosPicker(isPresented:selection:) modifier on the component's body Group. Files path unchanged. (2) Track cover in project tracklist: TrackRow gains imageURL: URL? = nil (default nil, backward compatible). The thumb is now a ZStack — AsyncImage over the gradient/waveform fallback, clipped with clipShape(Circle()). ProjectTracksView passes projectImageURL (threaded from ProjectRoute.image in the previous slice) so all rows in a project show the project cover art; gradient fallback for cover-less projects. (3) Intelligent accent halo: AdaptiveColor.vibrant(from:) added to AdaptiveGradient.swift — downsamples the cover to 20×20, scans all pixels in HSB, drops near-grey (saturation < 0.25), near-black (brightness < 0.10), near-white (brightness > 0.92), and picks the pixel with the highest saturation × brightness (chroma proxy). Returns nil for muted art → identity-accent fallback. PlayerViewModel.refreshArtworkAccent now calls AdaptiveColor.vibrant instead of the mean-based average; the halo should read as pulled-from-the-art rather than a grey smudge.

Added

  • Backend — image-delivery variants (doc 34, the cure for slow covers) (2026-06-14, Claude Code; tsc clean). The Sharp variant machinery (thumb 150² / medium 600px / original, all WebP) existed but was bypassed — cover uploads used plain uploadFile and only blobUrl was persisted, so clients downloaded full originals into 150px tiles. Now every cover upload generates + persists right-sized variants: (1) File schema gains thumbnailUrl? / mediumUrl?; (2) ArtistSpace + Project gain imageThumbnail? / imageMedium? (the entity image string is what tiles/heroes read). (3) Upload paths switched to uploadImageWithVariants + persist the variant URLs: track image (createTrack), track cover version-replace (image branch only — audio still plain), project image (create + update; were uploadFile), artist image (already used variants — now the returned URLs are saved instead of dropped). (4) Auto-signed: the global SignUrlsInterceptor signs any key containing "url"/"image", so the new fields sign on every read with zero extra wiring. (5) Backfillscripts/backfill-image-variants.ts rebuilds each image's deterministic key from its refs (createFileLink/…Space/…Project — no URL parsing), re-runs uploadImageWithVariants, updates the File and mirrors space/project covers onto their entity. Dry-run by default; only mutates with APPLY=true.APPLIED 2026-06-14 vs the cloud DB: 31 of 33 images now carry variants (File.thumbnailUrl/mediumUrl, entity imageThumbnail/imageMedium; blobUrls re-pointed to clean image.webp). 2 left un-fixable: 1 orphan (no blobUrl), 1 with a corrupt source blob (bad seek — truncated original; Sharp can't read) — both fall back to original/gradient on the client. Bug found + fixed mid-apply: existing File docs store a bare contentType ("png"/"webp"), but uploadImageWithVariants needs a full MIME ("image/png") or it silently falls back to a plain upload (no variants, blob named image.undefined); the script now normalizes to a real MIME (toMime) — the earlier bad run's image.undefined blobs are orphaned junk (harmless), blobUrls corrected on re-run. Also needs tsconfig-paths/register (imported at top). No CDN. ⚠️ Reaches TestFlight on the next container redeploy. iOS follow-up (queued): read thumbnailUrl/imageThumbnail for tiles/rows, mediumUrl/imageMedium for heroes, + a disk image cache. Decisions resolved (doc 34): explicit fields · batch backfill (dry-run-gated) · S3 variants deferred (Azure is live).
  • iOS image-picker polish — confirm + Files source (2026-06-13, Xcode agent; BuildProject green). New DesignSystem/Controls/ImagePickerButton.swift — a reusable two-source picker that replaces the bare PhotosPicker in all three portal image-upload paths. (1) Sources: a Menu presents "Photos" (PhotosPicker) and "Files" (.fileImporter, allowedContentTypes: [.image]). Both sources load image data, write a temp file, and decode a UIImage preview; the security-scoped URL from Files is unwrapped during the read. (2) Confirm step: after picking, a glass sheet shows the selected image (aspect-fit, RoundedRectangle clipped), the filename, and two buttons: "Use this image" (commits) + "Choose again" (clears). Temp file is cleaned up by the component on cancel/drag-dismiss or by the upload Task on confirm — no leaks. (3) Progress: while the caller's upload is in flight, an inline ProgressView replaces the menu — the progress Double? is still owned by the portal, unchanged. (4) Wired into: ArtistPortalContent (artist image section), ProjectPortalContent (album art section), TrackPortalContent (cover section). Each portal's old PhotosPickerItem state, handleImagePicked/handleCoverPicked function, and .onChange(of:) modifier are removed; the upload logic is extracted into a focused uploadXImage(fileURL:filename:mimeType:) method called from ImagePickerButton.onUpload. imageSubtitle/coverSubtitle updated to say "Choose from Photos or Files." instead of "photo library".

Envisioned

  • doc-33 Notes — 3 decisions locked (owner 2026-06-14). (1) explicit NOTE CommentType (not inferred). (2) rename Track.notesTrack.description — a distinct concept from NOTE-kind notes, so it gets its own name (additive copy migration). (3) tags-scope decided (resolves the doc-26 + doc-33 open question): tags[] are per-resource, owner-authored, private by default, propagating to a recipient only via an opt-in "share tags" check at share time (or with a shared workspace) — not a global vocabulary; distinct from the always-private recipient-side my-tag. Decisions 4–6 (note status, pin/elevate, roll-up filter) stay open for build time. Updated: 33-NOTES-ANNOTATIONS.md §Decisions + backend map, 26-IDENTITY-SHARING-NAVIGATION.md Wave-3 note. Still envision-only; nothing built.

  • Image delivery & performance — 34-IMAGE-DELIVERY.md (2026-06-14, Claude Code; owner-raised). Owner: covers load slowly on the Home grid; wants a low-cost alternative to a CDN. Finding: the cure is already built and unused. Verified the Sharp variant machinery exists (image.service.ts: thumb 150² / medium 600px / original, all WebP; azure/blob-storage.service.ts → uploadImageWithVariants uploads key-thumb/key-medium and returns their URLs) — but the track/project/artist cover uploads call the plain uploadFile (originals only, no variants) and File persists only blobUrl (variant URLs dropped). So the client downloads a full-res original to fill a 150px tile = the lag; the doc-22 §6 halo, derived from it, resolves late too. Approach (no CDN): right-size at the source + cache at the client — (1) route cover uploads through uploadImageWithVariants; (2) persist thumbnailUrl?/mediumUrl? on File (additive; signUrlsInObject signs them — and must persist, since per-blob SAS tokens can't be client-suffix-swapped); (3) client picks thumb for tiles/rows, medium for heroes; (4) disk-backed image cache (AsyncImage has none). CDN (doc 05) stays a later scale lever. Backend-first, near-term; 4 decisions flagged (storage shape — rec explicit fields · backfill existing originals — rec batch job · S3 parity — defer · thumb shape). Queued in next-brief.md (backend slice, awaiting storage-shape green-light). Cross-refs: roadmap doc-map, doc 22 §6, doc 05.

  • Notes as first-class annotations — 33-NOTES-ANNOTATIONS.md (2026-06-13, Claude Code; owner-raised). Owner: the Track Notes field should become real — many notes, each with who wrote it + when, surfaced in other parts of the hierarchy by tag, and maybe a status "if it makes sense." Grounded finding: the rich entity already exists — it's Comment. Verified CommentType already includes VERSION/PROJECT (non-timeline anchors), and Comment already has owner + timestamps (author + date/time) + threaded responses + isResolved + file/track/project/section refs. So a Note is "a Comment without a timeStart" — the single Track.notes string is the only un-modeled annotation. Doc envisions generalizing Comment into the universal annotation surface (no new collection — the doc-26/28/32 "don't rebuild" discipline): add a NOTE kind + an artistSpace anchor + tags[] (the cross-hierarchy surfacing the owner asked for = the same doc-26 #Tags lens, surfaced not copied); status is opt-in resolve (not a default field), elevating to a doc-32 {status,by,at} approval only for genuine sign-off notes (else the approval model would lie). Notes vs Comments = one entity, two flavors (node-anchored editorial in the Portal vs moment-anchored feedback in the Wave). Envision now, rides the Portals-everywhere phase; cheap early taste = add tags[] to Comment (serves notes and the doc-26 tag lens at once). Five decisions flagged. Cross-refs wired: roadmap doc-map, doc 24 §Notes + §Image picker. Also captured the owner's image-picker asks (confirm-selected-picture · pick from Files not only Photos · crop-as-a-later-component, owner-confirmed) into doc 24 + next-brief.md as the next iOS slice. Extended 2026-06-13 (same doc): added the eagle-level roll-up — comments "disappeared" because the Portal renders no annotation band; the fix (grounded in "Notes are Comments") gives every Portal two bands, Anchored-here + Rolled-up-from-below, filtered (unresolved · tagged · status) so artist/project/roster show a less-but-louder subtree digest while a version keeps today's comments (owner: "enough"). Needs a backend subtree-aggregate read; rides the Portals-everywhere phase. Sixth decision flagged (roll-up default filter). Also refined 22-IOS-DESIGN-SYSTEM.md §6 (owner-locked): the artwork halo must be intelligent, not averaged — read the most-vibrant accent (highest-chroma dominant cluster, drop near-grey/black/white), not the mean (which muddies to grey). Three iOS QA items queued in next-brief.md (Photos-picker regression — PhotosPicker-in-Menu gotcha · track cover missing in tracklist rows · intelligent halo). Cross-refs wired into doc 24 (§Identity halo, §Comments-in-Portals).

  • Approval as a first-class primitive — 32-APPROVAL-MODEL.md (2026-06-13, Claude Code; owner-raised). Owner connected several TestFlight observations into one: "approval is only in Wave; cover belongs in the Single/Project/CD area; approval should also cover lyrics, tracklist & order, edit/production phases, moreover mastering — ground this in the whole vision, use cases, actors and objectives." New envisioning doc generalizes doc-29 audio-version approval into "approval is a dimension, not a screen": one primitive { status, by, at } per (node, dimension) pair, scoped to where the thing lives (audio→File, cover→Project/release, lyrics→Track/version, tracklist→Project, master→phase gate), surfaced in Portals (doc 26 Layers 2 & 4) — the Wave keeps only the listen-bound audio verdict. Adds a production-phase ladder (Writing→Recording→Mixing→Mastering→Release, hybrid marker gated by approvals), a Project-level release-gate rollup (feeds the doc-12 distribution validation), per-dimension review history, and an actors×dimensions sign-off matrix (manager/A&R/legal sign-offs = the B2B value, doc 31). Grounds the codebase seed: ReleaseDimension { title, version, cover } already abstracts approvable axes; doc 29 already proved one axis can be re-homed onto its real node. Folds in the cover thread (§7): crop→frame (near-term client crop; iOS-27 generative resize/gen deferred to post-beta), hero-display (iOS wiring gap, not backend), cover-history CRUD (setCoverImage = promote ✅; delete-from-history + browser missing), cover-approval home → Project/Track Portal. Envision now, build after the doc-26 Shared waves; three cheap ride-alongs flagged (cover approval in the existing Project Portal · cover-history delete verb · hero cover display). Six decisions flagged for build-time. Cross-refs wired: roadmap doc-map, doc 29 (Future → generalized here), doc 28 §Cover art, doc 24 §Cover art (backlog items), next-brief.md (QA item).

Fixed

  • iOS cover display QA — doc-32 §7 ride-along (2026-06-13, Xcode agent; BuildProject green). Closes the remaining hero-display gaps after slice 9: (1) Project hero (ProjectTracksView header) now shows the project cover — ProjectRoute gains image: String? (default nil, backward-compatible init); ArtistsHomeView and SharedView pass project.nonEmptyImage in the route; navigationDestination handlers in both views thread route.image → URL → projectImageURL into ProjectTracksView.init, stored as @State and passed to ArtworkHero.imageURL. (2) Track empty-state hero (PlayerView.emptyVersionsState) now shows the track cover via detail.coverFile.flatMap { URL(string: $0.blobUrl) }. (3) Shared tab artist tiles (sharedArtistTile, byMeArtistTile) and project tiles (sharedProjectTile, byMeProjectTile) all thread their nonEmptyImage into ArtworkHero.imageURL / ProjectCard.imageURL. Crop is already correct: scaledToFill() + clipShape were baked into ArtworkHero in slice 9. Gradient fallback unchanged for cover-less nodes. No backend changes.

  • iOS browse surfaces now display uploaded artist/project images (2026-06-13, Xcode agent; BuildProject green). Slice-8 wired the upload path but left the display half out. Fix: (1) ArtworkHero gains imageURL: URL? = nil — shows AsyncImage over the gradient when set, gradient + placeholder icon as fallback on nil/loading/failure; shape-clipped correctly (Circle for artists, RoundedRectangle for projects). (2) ArtistTile (home grid) and ArtistDetailView hero both thread artist.nonEmptyImage → URL → ArtworkHero.imageURL. (3) ProjectCard gains imageURL: URL? = nil — artwork square becomes a ZStack with AsyncImage on top, gradient underneath; clip applied. ArtistDetailView project grid passes project.nonEmptyImage. (4) ProjectPortalContent empty portal fix: initial state was isLoadingDetail = false so the body rendered nothing and .task { loadDetail() } never fired; changed to true so the loading view shows on first render and kicks off the fetch.

  • Backend — uploaded images rotated 90° (EXIF orientation) (2026-06-13, Claude Code; tsc clean). Owner saw a portrait photo come out sideways on one artist but not another — the classic EXIF tell: phone photos store landscape pixels + an orientation flag, and Sharp's .resize()/.webp() drop the flag without applying it, so only images carrying the flag rotate. Fix: image.service.ts now calls .rotate() (no args = auto-orient from EXIF, baked into pixels) before resize in all three variants (createThumbnail, createMedium, optimizeOriginal). New uploads are correct; ⚠️ reaches TestFlight on the next container redeploy, and already-uploaded sideways images stay rotated (re-upload to fix). Investigated the "empty Project Portal" report too: GET /project/:id serializes fully (refs resolved, _id stringified) and the iOS ProjectDetail decoder is fully tolerant (every field optional/try?), so the backend is healthy — handed to the Xcode agent as a render/self-fetch QA item (see next-brief.md).

Added

  • iOS Track Portal share + per-version share — slice 9 (doc-26 Wave-2) (2026-06-13, Xcode agent; BuildProject green). Closes the Track Portal's dead "Share" button and adds per-version share links. (1) TrackPortalContent dead GhostButton("Share") replaced by a PortalShareSection reusing the same component from slice 8; endpoints: GET /collaboration/link/track/:trackId, POST /collaboration/share/track { trackId, role }, PUT role, DELETE revoke — owner/admin only (gate: onSave != nil). (2) New "VERSIONS" section in TrackPortalContent lists each audio file with a per-row share button → sets fileShareTarget → presents a Portal { PortalShareSection } sheet for GET/POST /collaboration/link/file/:fileId + POST /collaboration/share/file { fileId, role }. (3) CreateTrackShareLinkRequest and CreateFileShareLinkRequest added to ProjectArtistCRUDRequests.swift. Doc-26 §Build plan item 9 ✅.

  • iOS Artist + Project Portals — slice 8 (doc-26 Wave-2) (2026-06-13, Xcode agent; BuildProject green). Generalises the Track Portal pattern to the entire browse hierarchy. (1) CollaboratorUserDetail added to ArtistSpace.swift; ArtistCollaborator gains a custom Codable that normalises userId arriving as either a plain string or a populated User object (live since the backend fix shipped today). New entryId field carries the collaborator entry's own _id for the role-change PUT path. (2) ProjectDetail in Project.swift — full project shape fetched for the portal, with collaborators, owner (polymorphic decode), artistSpace, and description. ProjectParentArtist for the parent label. (3) APIConfig.webOrigin constant (sonnance.ff8.dev / localhost) for share-link URL construction. (4) APIClient.uploadFile gains method: String = "POST" so artist/project image uploads can use PUT. (5) CollabLink (renamed from ShareLink to avoid SwiftUI name collision) added to SharedItems.swift — full link document decoded from the portal's share-link endpoints. (6) Collaboration request types added to ProjectArtistCRUDRequests.swift: UpdateCollaboratorRoleRequest, InviteToArtistRequest, InviteToProjectRequest, CreateArtistShareLinkRequest, CreateProjectShareLinkRequest, UpdateShareLinkRoleRequest. (7) PortalTeamSection (PortalSharedSections.swift) — collaborator list with per-row avatar (image or initials), name, role; owner/admin gets a Menu per row (change role → PUT; remove → DELETE + confirmationDialog) plus an inline Invite row (email + role picker → POST). (8) PortalShareSection (same file) — fetches existing link on appear; "Generate share link" GhostButton when none; with a link: URL + copy + SwiftUI ShareLink, role picker (PUT → refetch), opens/last-access metadata, destructive Revoke (DELETE + confirmationDialog). (9) ArtistPortalContent (Features/Browse/) — header with Edit/Save name, Roster row (taps → RosterEditorSheet; owner/admin only), Projects count, Artist image section (PhotosPicker → multipart PUT, owner/admin only), Team section, Share section, My-tag row (non-owners). (10) ProjectPortalContent (sibling) — self-fetching ProjectDetail on appear; metadata (name, type, description editable; parent artist read-only); Album art section (this is the canonical cover per doc-28); Team, Share, My-tag. Both portals presented via a new info.circle toolbar button in ArtistDetailView and ProjectTracksView respectively. MyTagChip, TagEditorSheet, and RosterEditorSheet promoted from private to internal for cross-file reuse. Doc-26 §Build plan item 8 ✅.

Changed

  • Backend — GET /artist/:id now populates collaborators.userId (2026-06-13, Claude Code; tsc clean both repos). Groundwork for Wave-2 slice 8 (Artist/Project Portals need collaborator names + avatars). The artist list (GET /artist) and both project reads populated all along — only the artist single reads (findOneById / findByIdentifier, all three lookup branches) returned bare id strings, so this makes the artist API self-consistent rather than introducing a new shape. owner intentionally stays a plain id (the controller serializes it with .toString()). Web front: getCollaboratorRoleArtist (src/lib/collaborators/index.tsx) compared userId === session.user._id and would have silently downgraded everyone to viewer on the populated shape — now accepts both string and populated-object userId. Same TestFlight dependency: live after the next container redeploy.

Fixed

  • Backend — roster was silently dropped by PUT /artist/:id (2026-06-13, Claude Code; tsc clean). Doc-26 Wave-1 item 1 added roster?: { name, color } to the ArtistSpace schema + DTO and documented it as "accepted via the existing PUT", but the controller's field-by-field update whitelist (artistSpace.controller.ts update()) never copied roster into the payload — so setting a roster was a no-op on both the JSON and image-upload branches. Both branches now pass roster through (!== undefined, so { "roster": null } clears it; the service spread persists it as-is). Caught while writing the Wave-2 slice-7 (Roster UI) brief, before any client consumed the broken contract. Same TestFlight dependency as the rest of Wave 1: live after the next container redeploy.

Added

  • iOS Roster UI — name+color on artists; Artists home groups by roster (2026-06-13, Xcode agent; BuildProject green). Doc-26 Wave-2 slice 7 — roster as a soft grouping facet on ArtistSpace. Solo-user invariant strictly preserved: zero new chrome until a roster actually exists. (1) ArtistSpace model extended with ArtistRoster { name?, color? }, ArtistCollaborator { userId?, role? }, and corresponding roster, owner, collaborators fields (all permissive optionals). New helper isOwnerOrAdmin(_ userId:) checks whether the current user is the artist's owner or has "admin" in the collaborators array — used to gate the context menu item. (2) UpdateArtistRosterRequest added to ProjectArtistCRUDRequests.swift: .set(name:color:) encodes { "roster": { "name": ..., "color": ... } }; .clear() encodes { "roster": null } via encodeNil(forKey:) (explicit null signals the backend whitelist to clear the field; omitting the key is a no-op). (3) ArtistsHomeView updated:ArtistsHomeViewModel gains hasAnyRoster, rosterGroups: [ArtistRosterGroup], updateRoster(artistId:name:color:), and clearRoster(artistId:). The grid switches between flat layout (no rosters anywhere) and sectioned layout (one section per roster name with a small color-dot header + artist count, plus a quiet "Unassigned" section at the end). Each artist card in both layouts gets a long-press context menu; "Roster…" appears only when artist.isOwnerOrAdmin(currentUser.id) is true. The menu opens RosterEditorSheet — a glass sheet (.thinMaterial, same presentation as TagEditorSheet) with a name TextField, 8-swatch palette (MyTag.paletteHex / Theme.versionColor), "Save roster" / "Update roster" primary button, and a "Remove from roster" ghost button when a roster already exists. Success → refetch + dismiss. (4) New RosterCapsule design-system component (struct, not private) — color dot + name in the palette color, used as the artist tile's subtitle when a roster is set (replacing the plain "Artist" label). (5) SharedView ("With me" segment) — inbound sharedArtistTile now shows a RosterCapsule in place of "Artist" when entry.space.roster is present; read-only for recipients (no context menu on this side). Out of scope: roster as a filter lens (Wave 3); Artist Portal (slice 8 — will replace the context menu as the canonical roster-edit surface). Doc 26 §Build plan Wave-2 item 7 ✅. ⚠️ Same TestFlight dependency as Wave 1: live after the next container redeploy.

  • iOS Shared screen v2 — "By me" outbound segment (2026-06-13, Xcode agent; BuildProject green). Doc-26 Wave-2 slice 6 — the team-lead / B2B half of the Shared tab, riding the live GET /collaboration/shared-by-me aggregate. (1) Models — extended SharedItems.swift:SharedByMe { spaces, projects, tracks }; each section's entries flatten the underlying resource alongside shareSummary via init(from:) (same pattern as SharedArtist). New ShareSummary (collaborators / sharedWithCount / links — every field permissive, derived totalOpens, linkCount, hasFileVersionLink), SummaryCollaborator (Identifiable so ForEach is stable even when userId._id is missing), SummaryUser (_id, username, fullName, image, email — all permissive), ShareLinkSummary (id, role, resourceType, timesOpened, lastAccessAt, expiresAt, optional file for per-version share links). (2) SharedView gains a segmented Picker at the top of the screen — "With me" / "By me" — using the system .segmented style (consistent with iOS; styling work parked until the rest of the doc-26 IA settles). Default is "With me" (recipient-side is what most users open the tab for). The two feeds are independent: a single .task fires both inbound + outbound in parallel via async let so the segment switch is instant, and pull-to-refresh reloads only the active segment (the other cache stays warm). (3) SharedViewModel gained byMe: SharedByMe? + isLoadingByMe + byMeError + loadByMe() (single fetch, 401 → signOut, error surfaced only when the segment is empty). (4) Outbound sections reuse the same ArtworkHero / ProjectCard / TrackRow tiles via dedicated byMe…Tile/Row helpers, swapping the my-tag chip slot for a new ShareSummaryLine: overlapping initials avatars for up to 3 collaborators (Theme.accent(forKey:) per-user, identity-stable; +N pill when more than 3) + caption "N link(s) · M opens" + a small waveform capsule chip when shareSummary.hasFileVersionLink (a specific version is shared). Initials fall through fullName → username → email; the chip and caption read as quiet metadata, never a CTA. (5) Navigation reuses the existing ArtistRoute / ProjectRoute / String (trackId → PlayerView) destinations from inbound. (6) Empty state for "By me" uses a paperplane glyph + outbound-specific copy ("Resources you share — by adding collaborators or generating a link — land here with their open counts."). Tag editing context menu stays on the inbound segment only (outbound's focal metadata is the share summary; tags-on-own resources would be noise — parked behind doc-26 lenses/saved views). Out of scope: link management (revoke / expiry editing — Portal work, later slice); roster UI; identity badges outside the Shared tab; lenses. Doc 26 §Build plan Wave-2 item 6 marked ✅. ⚠️ Lands on TestFlight after the next container redeploy (same dependency as Wave-1 backend).

  • iOS Shared screen v2 — inbound feeds + my-tag chips (2026-06-12, Xcode agent; BuildProject green). Doc-26 Wave-2 slice 5 — the first iOS consumer of the Wave-1 backend contracts. Replaces the 2026-06-07 SharedView that was pulled while the model was undecided. (1) Models — new Core/Models/SharedItems.swift: SharedArtist (custom init(from:) because /collaborator/spaces flattens role + myTag onto the ArtistSpace object), SharedProject

    • SharedTrack (synthesized Decodable — wire shape is { project|track, role, myTag }), a permissive MyTag { label?, color? } with isPresent so the backend's { label: "", color: "" } empties don't render chips, and UpsertMyTagRequest (manual encode(to:) so nil is omitted rather than null'd). SharedResourceType enum mirrors the backend SharedResource ("ArtistSpace" / "Project" / "Track" exact strings). (2) SharedView + SharedViewModel: three parallel inbound fetches (per-feed try/catch — a partial outage doesn't blank the tab; aggregated 401 routes through AuthManager.signOut); sectioned artwork-forward layout — Artists (2-col grid, ArtworkHero(.circle) + name + role caption), Projects (2-col grid, ProjectCard), Tracks (vertical TrackRows). Identity colors via Theme.accent(forKey:). Navigation pushes the existing ArtistRoute / ProjectRoute / String (trackId → PlayerView) destinations through a per-tab NavigationPath. (3) My-tag chips render as a small Capsule (tag-color fill at 0.18, text in tag color) — same pill language as the player's V# bubble, intentionally quiet so it reads as metadata. Color-only tags get a placeholder. Unparseable hex falls back to snForegroundSecondary. (4) TagEditorSheet (one component reused for all three types): label TextField + an 8-swatch palette (Theme.versionColor(for: 0..<8) visuals → MyTag.paletteHex on the wire), Save / Update / Remove buttons. Sheet is opened via a .contextMenu { Button "Tag…" } on every row/tile (long-press); save/remove route through PUT/DELETE /collaboration/my-tag and reload the feed. (5) RootTabsView: the Shared tab is back — Home / Shared (tray.and.arrow.down) / Recents — this time grounded by the doc-26 model so it stays. Out of scope: outbound "Shared by me" (next slice — endpoint already live); roster UI on Artist surfaces; identity badges on non-Shared screens; lenses/filter bar; Portals-everywhere. Doc 26 §Build plan Wave-2 item 5 marked ✅. ⚠️ Lands on TestFlight after the next container redeploy (doc 27 step 2 — same dependency as the Wave-1 backend).
  • Doc-26 Wave 1 — backend implemented (2026-06-12, Claude Code; tsc clean + Nest boot-verified, all five routes mapped). (1) Roster tag: roster?: { name, color } on the ArtistSpace schema (typed subdoc, _id:false) + ArtistSpaceDTO.roster — set via the existing PUT /artist/:id, returned by all artist reads. (2) Recipient-side share tags: new ShareTag collection (unique per user × resourceType × resourceId) + PUT /collaboration/my-tag (upsert, UpsertMyTagDto) + DELETE /collaboration/my-tag/:resourceType/:resourceId; the three inbound reads (/collaboration/collaborator/{spaces,projects,tracks}) now return myTag: { label, color } | null per entry (batch-resolved via one $in query). (3) Outbound aggregate: GET /collaboration/shared-by-me{ spaces, projects, tracks }, each entry = the owned resource + shareSummary (external collaborators populated username fullName image email, sharedWithCount, link list with role / timesOpened / lastAccessAt / expiresAt; File links roll up to their parent track and keep the file ref). Backed by new LinkService.findAllLinksByOwner. (4) Per-version share links:SharedResource.File added; POST /collaboration/share/file (makeFileShareableDto { fileId, role }) validates file→track, raises track.sharedLinkRole (null-safe getHighestRole), and creates the link with resourceId = fileId plus track + file deep-link refs (extended createShareResourceLink); GET /collaboration/link/file/:fileId lists; addUserToLink gained the File branch — grants access on the parent track and returns { track, file } so clients land on the exact version. Invariant held: tags/lenses are organizational only; roles gate. ⚠️ Reaches TestFlight on the next container redeploy (doc 27 step 2). Doc 26 §Build plan marked Wave 1 ✅; Wave-2 slice 5 (Shared screen v2 inbound) queued in .agent/next-brief.md.

  • Doc-26 phase kickoff — build plan + Wave-1 backend contracts sketched (2026-06-12, owner: "go doc-26"). Grounded the contracts against the real modules first: Link already has a file ref and resourceType is a loose string (per-version links are cheap); collaboration has full invite/ collaborator/link CRUD at all three levels; real gaps = the outbound aggregate, the recipient-tags store, and the roster field. §Build plan added to 26-IDENTITY-SHARING-NAVIGATION.md: Wave 1 backend (roster {name,color} on ArtistSpace via existing PUT · new shareTag collection + PUT/DELETE /collaboration/my-tag + myTag merged into inbound reads · GET /collaboration/ shared-by-me aggregate · SharedResource.File + POST /collaboration/share/file granting the role on the parent track with a version deep-link ref); Wave 2 iOS slices (Shared v2 inbound → outbound → roster UI → Portals-everywhere incl. Project album art → identity badges + share-this-version); Wave 3 lenses/saved views client-first. Invariant: lenses filter, roles gate. Owner also reconfirmed cover-as-release-art ("more from the project rather the track") — noted in doc 28 §Cover art; the hierarchy work lands in this phase. Roadmap doc-26 row → build phase; contracts awaiting owner veto/green-light before Wave-1 code.

  • iOS — artwork-derived adaptive halo (doc 22 §6's personalization signature, finally wired) (2026-06-12, Claude Code, feel work; xcodebuild green). The player's ScreenBackground halo now derives from the cover's average color: PlayerViewModel.artworkAccent downloads the cover once per file id (not URL — SAS signatures rotate per fetch), samples it with the long-dormant AdaptiveColor.average(from:) (CIAreaAverage), and the halo crossfades at hero pace (0.8s ease) from the track's identity accent to the artwork color — quiet and inevitable per doc 22 §4. Fallback chain preserved: artwork → identity accent (no cover / load failure); recomputes on cover change (upload or set-cover → refetch). PlayerBar fallback gradient + empty-state hero stay identity-colored. Prod note: owner redeployed the container — set-cover + the per-version review endpoint are live on TestFlight; the cover-history rail shipped (Xcode agent) the same day. With this, the cover model is complete except Project/album art, which rides the doc-26 phase. Next: the doc-26 sharing/Rosters/lenses phase kickoff (backend-contract-first, per the routing rule).

  • iOS cover history — "Previous covers" rail in the Portal (2026-06-12, Xcode agent; BuildProject green). Mechanical slice from .agent/next-brief.md, follow-up to the cover model unblocked by the new backend set-cover verb. (1) PlayerViewModel.setCover(fileId:)PATCH /track/:trackId/file/:fileId/set-cover (no body), APIEmptyResponse, then fetchDetail() refreshes the populated detail; mirrors the updateVersionReviewStatus handshake. New isSettingCover

    • setCoverError state surfaces in-flight + failure to the Portal. Inherits the 401-signs-out, http-body-decode error path from the audio uploader. (2) TrackPortalContent gains isSettingCover / setCoverError / onSetCover params and a new "Previous covers" rail rendered under the existing cover preview when detail.image.count > 1. The rail is a horizontal ScrollView of ~56pt rounded-control thumbs (AsyncImage on each image's blobUrl; Color.snSurface placeholder
    • hairline border) showing every entry in detail.image whose id isn't the current coverFile's (natural insertion order — matches versionSwitcherSheet). Tapping a thumb sets a pendingSetCover state which drives a confirmationDialog ("Use this as the cover?" / "The current cover stays in history."); confirming calls onSetCover(fileId) → backend → refetch — main preview, PlayerBar artwork, and the rail itself all rerender. Rail disabled (50% opacity) while either upload-new or set-cover is in flight, so the user can't double-fire. setCoverError joins the existing pick/upload error line. (3) PlayerView threads the new state + closure through to the Portal. Out of scope: deleting cover-history entries, per-cover review status, Project/album art (doc-26 phase), artwork-derived screen halo (Claude Code, feel — next). ⚠️ Lands on TestFlight after the next container redeploy (doc 27 step 2 — same dependency as the backend set-cover verb).
  • Backend — set-cover verb + cross-type final-flag bug fix (2026-06-12, Claude Code; tsc clean). (1) New PATCH /track/:trackId/file/:fileId/set-cover (no body) — promotes an existing image version to be the track's cover: de-finals the current final image (type-scoped), marks the chosen file final, points track.imageFinal at it; cover history stays intact in image[] (doc 28 §Cover art). Guards: isObjectId, file-belongs-to-track (404), image-only (400). No role gate beyond track membership — consistent with cover upload, which has none. Unblocks the iOS "choose among uploaded covers" rail (queued in .agent/next-brief.md for the Xcode agent). (2) Latent bug found & fixed while grounding: createNewFile's de-final filter (fileService.findAndUpdate = updateMany) had no type constraint — uploading an audio version also cleared the final image's flag and vice versa, corrupting per-file final state across types (display survived via track.audioFinal/ imageFinal fallbacks, which masked it). Filter now scoped { type: fileType }; same scoping used in the new set-cover verb. A dry-run repair script against the live DB found 0 corrupted flags (the fallbacks had kept refs truthful), so no data repair was needed. ⚠️ Set-cover reaches TestFlight on the next container redeploy. Next per the confirmed plan: iOS cover-history slice (Xcode agent) → artwork- derived player halo (Claude Code, feel) → doc-26 sharing/Rosters phase.

  • iOS track metadata completeness — Portal editor covers ISRC / UPC / notes / mainArtists (2026-06-12, Xcode agent; BuildProject green). Mechanical slice from .agent/next-brief.md, targeting the 24-UX-BACKLOG.md "Metadata fields — completeness + sync" item. The editable Track Portal already covered name / label / genre / releaseDate; this slice fills in the rest of the release-metadata strings the backend TrackDTO accepts (ISRC, UPC, notes, mainArtists). (1) UpdateTrackRequest gained isrc, upc, notes, mainArtists (all optional) — explicit named init so call sites stay parameter-order-tolerant; CodingKeys map isrc → "ISRC" / upc → "UPC" (uppercase on the wire per track.dto.ts); manual encode(to:) still omits nil so partial PUTs never clobber unrelated fields with null. (2) TrackDetail decodes the matching optionals (uppercase ISRC/UPC CodingKeys, permissive decodeIfPresent). (3) TrackPortalContent edit mode adds rows below release date — Main artists / ISRC / UPC as single-line editableRows (ISRC = .characters autocap + autocorrect off; UPC = .never autocap

    • autocorrect off; both with number/barcode glyphs) and Notes as a new multilineEditableRow (TextField(axis: .vertical), lineLimit(2...6), label-on-top to avoid MetadataRow's trailing-overflow). Read mode shows the new fields via MetadataRow (hiding empties), with Notes in a vertical notesReadBlock so long notes don't get clipped. Drafts seeded from detail.*; save routes through the existing PUT /track/:id + refetch handshake using normalizedField (empty + previously-non-empty → explicit "" to clear; unchanged → nil to omit). Out of scope: structured credits (later — needs a contributors editor), validation/format checks, version-scoped metadata, sharing, cover history. Backlog item struck in 24-UX-BACKLOG.md.
  • Prod redeployed + QA round closed · PlayerBar tap-through fix → bar-tap opens the Portal (2026-06-12). Owner redeployed the container (doc 27 step 2) and tested TestFlight: the per-version review endpoint is now live in prod (version chip works end-to-end), lock-screen playback verified (audio continues when locked; lock-screen transport works), halo/gesture/Link-id passed earlier, ring feel approved "for the moment". One bug found: the floating PlayerBar didn't consume touches on its non-control areas — tapping the title hit comments scrolled behind the glass pill. Fix (Claude Code, xcodebuild green): .contentShape(RoundedRectangle) over the whole pill + .onTapGesture { onPortal() } — every touch on the bar is consumed, and tapping anywhere that isn't a control now opens the track's Portal (the owner's expectation, and the precursor of doc-30 Phase 2's mini-player tap-to-expand; child play/Portals buttons keep priority). Accessibility hint added. Plan confirmed unchanged: metadata-completeness slice (Xcode agent, queued in next-brief.md) → set-cover verb + artwork halo (Claude Code) → doc-26 sharing/Rosters phase.

  • iOS — PlayerBar artwork control: breathing-ring playback signal · cover/release envisioning (2026-06-12, Claude Code in-house; xcodebuild green). Owner QA on the cover slice: the .ultraThinMaterial disc + glyph read as a "layer on top" covering the artwork. Redesigned per the owner's vision: paused = artwork unobstructed except a small white play glyph legible via shadow (no covering disc); playing = the glyph fades out (0.25s) and the circle carries a breathing light ring — a hairline white(0.7) stroke on the edge plus a slow sonar-quiet pulse (TimelineView-driven outer ring, 2.4s cycle, expanding ×1.3 while fading from 0.4 → 0) — the owner's "wave signal out of the circle". Reduce Motion → static stroke only; the whole thumb remains the play/pause button (accessibility label carries the state). Consistent with doc 22 §4 (quiet, inevitable transitions). Cover questions envisioned in doc 28 §"Cover art & releases": covers already version (image[]/imageFinal mirror audio[]/audioFinal; history UI waits on the queued set-cover-from-existing verb); cover ownership follows the release — Single → track art, Album/Compilation → Project art via the Project Portal (Portals-everywhere phase), display preference track → project → gradient; with Option C multi-membership a track shows that release's art in context. Per-cover approval slot already exists (releaseStatus.cover), can adopt the doc-29 per-file pattern later. Doc 24 cover-model item cross-linked. QA still pending from the owner: lock-screen playback test.

  • iOS cover model — Portal manages, PlayerBar displays (2026-06-12, Xcode agent; BuildProject green). Mechanical slice off .agent/next-brief.md, settled design from 24-UX-BACKLOG.md ("The cover model"). (1) Models: TrackDetail now decodes image: [MediaFile] (custom init(from:) so the field is permissive — defaults [] for older payloads) and imageFinal: String?, plus a coverFile: MediaFile? helper that resolves imageFinalisFinalVersion == true → last upload (mirrors how audioFinal is consumed for versions). (2) Portal — Cover section (TrackPortalContent): rounded-square ~96pt preview (AsyncImage on coverFile.blobUrl; identity-accent gradient placeholder), with a PhotosPicker(matching: .images) "Add / Change cover" button below. Picked items resolve a real image/* MIME from PhotosPickerItem.supportedContentTypes (never application/octet-stream, which the backend would 400), get written to a temp file, and go up through the existing APIClient.uploadFile(fieldName: "file", …) multipart path — same plumbing slice-5 uses for audio versions; the backend's existing image branch flips imageFinal automatically and the parent refetch surfaces the new cover everywhere. Local upload-error + pick-error UI matches the UploadVersionSheet pattern. (3) PlayerBar artwork: new optional artworkURL: URL?; when present, AsyncImage renders under the existing .ultraThinMaterial disc + play/pause glyph (artwork stays focal, glyph stays legible on any cover color). Falls through to the identity-accent gradient on nil / loading / failure — no placeholder squares, no flicker. (4) Wiring: PlayerView passes identityAccent for the placeholder and threads viewModel.uploadCover(…) (mirrors uploadVersion but no version-switch on success — refetch is enough). Out of scope (queued): picking among already-uploaded images (needs a backend set-cover-from-existing verb — Claude Code, next backend round); artwork-derived adaptive halo on the player (Claude Code, feel work).

  • Docs — the cover model settled + slice queued (2026-06-11). Owner asked where covers (and brand images) are added/reviewed/edited and whether they appear on the wave page. Grounded against the backend: Track.image[] + imageFinal exist, GET /track/:id already populates image, and an image/* upload through the existing POST /track/:id/file automatically becomes the cover (final-flipping is generic; the doc-29 reviewStatus/notification additions were correctly audio-only). Design locked in 24-UX-BACKLOG.md ("The cover model"): manage in the Portal, display on the wave — Portal gets a Cover section (current cover + Change cover → PhotosPicker → existing multipart plumbing); the wave shows the cover only as supporting elements (PlayerBar thumb artwork now; artwork-derived adaptive halo later, feel work). Gap found: no set-existing-image-as-cover verb — small backend addition (Claude Code, next backend round); upload-new works today. Artist/Project brand images = same pattern via their own Portals, landing with the doc-26 Portals-everywhere phase. Mechanical iOS slice written to .agent/next-brief.md (models decode image[] → Portal cover section → PlayerBar artwork), gated on the pending player QA round.

  • iOS scroll-linked halo + id relocation · workspace doc audit · 31-PRODUCT-VISION-360.md (2026-06-11, Claude Code; xcodebuild green). (1) Halo transition is now scroll-linked (owner: the no-halo→halo moment is a brand moment): the binary isWaveformPinned toggle + 0.18s animation became pinProgress (0…1 over a 56pt approach band, computed in onScrollGeometryChange) — the halo opacity rides the user's own gesture, the nav title follows with pinProgress² (arrives a beat later), nothing pops; the principle is codified in doc 22 §4 "Motion & state transitions" (scroll-linked states interpolate with the gesture; discrete changes = quiet 150–250ms; "is there a visible instant where the UI flips?"). (2) Logical id off the wave page: the slug/shortId line under the player title removed — it now lives in the Track Portal's Label Copy table as a "Link id" row (ids belong with sharing/metadata, not hero content). (3) Workspace-wide doc audit (Explore agent sweep of all *.md, 4 repos) — fixes applied: ctrl-audio-back/CLAUDE.md "Known gaps" was actively misleading (still claimed no approval endpoint / Apple Sign In / refresh / APNs) → replaced with current Status + real remaining gaps; front docs/12-UI-UX-DESIGN-SYSTEM.md re-branded WAVIC→Sonnance + scoped to web + reciprocal link to doc 22; archive/supersession banners added to infra docs 15, 16 and a live-API note to 03 (api.sonnance.ff8.dev, no /v1); doc 23's parked Sharing note now points at doc 26 directly. Remaining recommendations (README↔CLAUDE sync) parked. (4) 31-PRODUCT-VISION-360.md — owner-locked brand/business layer: audience = professional-led ("every user is a music business"; B2B center of gravity — teams/labels/studios — with solo pros as the bottom-up adoption engine; doc-26 sharing/Rosters therefore = next major build after polish); monetization deferred entirely (Stripe stays stubbed, archived tiers not commitments); brand pillars = craft & reverence (sanctuary) + professional calm (anti-chaos), with fair credit & attribution as the standing later-phase commitment (doc 28 keeps preparing the data); north star = the collaboration OS of music, expanding into the creator business hub (credits, contracts, splits, royalties) as the differentiated B2B destination. Doc-31 row added to 00-ROADMAP.md.

  • iOS — QA round 3: waveform halo + gesture intent + background audio; doc 30 envisioning (2026-06-11, Claude Code in-house; xcodebuild green). (1) Waveform backing, take 3: the gradient scrim still drew a visible "section" edge below the title (white in light mode) — replaced with a feathered halo owned by the waveform: a blurred (blur(18)) rounded snBackground.opacity(0.88) fill hugging the bars; no hard edges, no band. (2) Gesture intent state machine (Waveform): a touch is undecided until ~10pt of travel — horizontal commits to scrubbing, vertical = cancelled (the user wanted to scroll, not the timeline; no seek fires), and >80pt vertical pull mid-scrub bails out (slide-off-to-cancel, fires onScrubCancelled). Lifting while undecided = tap (comment-marker hit-test, else tap-to-seek). Fixes the reported "tap waveform then scroll accidentally moves the timeline". (3) Background audio (owner ask): UIBackgroundModes: audio added to Info.plist (alongside remote-notification); PlayerViewModel now publishes Now Playing metadata (MPNowPlayingInfoCenter — version name / track / duration / position, updated on state changes only) and registers remote transport (MPRemoteCommandCenter: play, pause, toggle, scrub) so the lock screen / Control Center / headphones control playback; command targets + now-playing teardown live in the ObserverHolder non-isolated deinit. Scoped to the wave page — leaving it ends the session by design until the global player. (4) 30-GLOBAL-PLAYBACK.md — the owner's "Sonnance as a music player" vision, phased: Phase 1 ✅ (the above); Phase 2 PlaybackManager extraction (app-scoped player that survives navigation) + a floating glass mini player above the tab bar (cover-as- play/pause + marquee + accent progress hairline; tap → wave page; swipe → dismiss; zero chrome when idle); Phase 3 favorites-as-queue (a doc-26 lens + small backend favorites store), loop, continuous play, AirPlay/Live-Activity stretch. Doc-30 row added to 00-ROADMAP.md; doc-24 QA-round-3 section added.

  • Version-scoped approval, end-to-end (29-VERSION-SCOPED-APPROVAL.md) + waveform scrim fix (2026-06-11, Claude Code in-house; backend tsc clean, iOS xcodebuild green ×2 — pending owner visual QA). Owner decisions locked: the unit of approval is the File (per-version reviewStatus), and a new upload resets to Under review. Backend: File.reviewStatus/reviewStatusBy/reviewStatusAt (plain-string enum to avoid a file→track type dependency, default 'No status'); new PATCH /track/:trackId/file/:fileId/review-status (UpdateFileReviewStatusDto) → updateFileReviewStatus (owner/admin gate, file-belongs-to-track check, collaborator notification "set V{n} of {track} to {status}"); mirror — when the file is final, releaseStatus.version is updated too, so web/legacy clients stay truthful; createNewFile/createTrack now start fresh audio at Under review and reset the mirror ($push + releaseStatus.version in one update). Data backfill (one-off script, applied to the live Atlas DB — additive/safe for the not-yet-redeployed prod image): 33 audio files; finals inherited their track's releaseStatus.version, others 'No status'. iOS: MediaFile.reviewStatus (permissive), UpdateFileReviewStatusRequest, ApproveBar.versionStatusOverride, and PlayerViewModel.updateVersionReviewStatus — the player's Version chip now shows and sets the verdict of the exact version being heard (switching versions switches the chip); Title/Cover keep the track-level PATCH. ⚠️ TestFlight needs a container redeploy (doc 27 step 2) before the new endpoint exists on prod. Waveform scrim (owner QA): the pinned header's .ultraThinMaterial band read as an off-brand gray strip — replaced with a top-anchored snBackground gradient scrim (0.94 → 0.82 → 0) so comments dissolve under the waveform; hairline removed. Doc 29 added to the 00-ROADMAP.md map; doc-24 version-approval item struck; doc-28 row marked Option C confirmed.

  • iOS — collapsing sticky waveform + stable identity colors, implemented in-house by Claude Code (2026-06-11; build verified green via xcodebuild, pending owner visual QA — checklist in .agent/next-brief.md). Context: the Xcode agent's build of the collapsing-header brief was reverted by the owner ("didn't work as expected"). Root cause found in code review: Waveform attached its scrubber with a plain .gesture(DragGesture), which loses to the ScrollView pan the moment the waveform moves inside a scroll view — exactly what scroll-then-stick requires. This class of layout/feel work is now routed to Claude Code directly (new "Routing" section in .agent/workflows/ios-feature-slice.md); the Xcode agent keeps mechanical slices + visual QA. (1) Collapsing header (PlayerView): single ScrollView + LazyVStack(pinnedViews: [.sectionHeaders]) — title/version/timecode scroll away (height measured via onGeometryChange), the waveform is the pinned section header (.ultraThinMaterial backing + hairline fade in when pinned, driven by onScrollGeometryChange against the measured threshold), and the track title fades into the nav bar as a brand-font principal toolbar item. (2) Waveform gesture rework: .highPriorityGesture so scrubbing beats the scroll pan; comment markers are now visual-only and their taps are resolved by hit-testing in the drag's onEnded (near-zero translation + top marker band + nearest marker within 16pt) with a new onScrubCancelled callback so a marker tap doesn't leave the scrub preview stuck (a high-priority gesture would have starved the old marker Buttons). (3) Stable identity colors: Theme.accent(forKey:) — FNV-1a over the entity id (replacing index-seeding and launch-randomized String.hashValue) — applied to artist + project + track across ArtistsHomeView, ArtistDetailView (header + project grid), ProjectTracksView (header + track rows), RecentsView rows, and the player (screen halo, PlayerBar, empty-state hero). Semantic now explicit in Theme: identity color = who/what it is (accent(forKey:)) vs version color = which revision (versionColor(for:) — version pill, version cards, waveform bars, Portal version dots, all unchanged). (4) ApproveBar reposition (owner decision, same session): the owner chose status chips in the scrolling header over a PlayerBar pill or Portal-only placement. ApproveBar gained a Style (.bar historic glass strip / .inline compact hugging chips); the player renders .inline chips under the version line (they scroll away with the title — approval is an occasional act, not a constant control), and the bottom stack is now just composer + PlayerBar. Backlog reposition item struck; the "reads as status, not action" affordance note stays open for post-QA review. Second xcodebuild run green.

  • Docs — third TestFlight feedback round triaged (player header + color scope + comment timing). Player interaction polish shipped; new feedback captured in 24-UX-BACKLOG.md and seeded as the next slice (.agent/next-brief.md): (1) collapsing sticky waveform — the player header should scroll up with the comments until the waveform reaches the top + safe-area, then pin (native LazyVStack(pinnedViews: [.sectionHeaders]) with the waveform as the pinned section header), and the track title becomes a native .navigationTitle header (large → inline on scroll); ApproveBar reposition still held. (2) Accent-color consistency extended — the queued stable-hash Theme.accent(forKey:) fix must cover artist + project + track (artist may already be aligned; extend to project/track), queued behind the waveform slice. (3) Comment timing precision [P2] — capturing a comment uses the instantaneous playhead (too coarse); future item to let users fine-tune the exact second (stepper / type-the-timecode at compose time + edit-after).

  • iOS — Player interaction polish slice (all four TestFlight items shipped). Pure iOS, no backend. (1) End-of-track reset: PlayerViewModel now observes AVPlayerItem.didPlayToEndTimeNotification on the current item, re-registers on version swap, and tears down via a small ObserverHolder sidecar (Swift 6 strict concurrency blocks nonisolated mutable storage on @MainActor @Observable classes, and a class deinit can't read main-actor-isolated state — the holder owns the observer tokens and removes them in its own non-isolated deinit). On end-of-track: pause, isPlaying = false, seek to .zero, so the control returns to the play glyph and the track is immediately re-playable. (2) Composer above keyboard (core-loop blocker): CommentComposer takes an external FocusState<Bool>.Binding (with internal-fallback when none is passed); PlayerView drives isComposerFocused and, while focused, hides ApproveBar + PlayerBar so the composer is the only bar above the keyboard. Default SwiftUI safeAreaInset(edge: .bottom) avoidance lifts the inset, and the comments ScrollView adds .scrollDismissesKeyboard(.interactively) so a swipe closes the keyboard. (3) Tap a comment → seek: CommentCard rows gain an .onTapGesture that calls viewModel.seek(to: comment.timeStart ?? 0) — the inverse of the existing waveform-dot → scroll-to-comment direction; context-menu / swipe actions still work. (4) Waveform-as-scrubber + pinned: the separate Slider is gone; Waveform exposes onScrubChanged / onScrubEnded driven by DragGesture(minimumDistance: 0) (taps and drags share one recognizer). The host suppresses periodic-time-observer writes mid-drag via isScrubbing and seeks on release. loadedContent was restructured so title / version label / focal timecode / waveform sit in a pinned VStack above the comments ScrollView — the waveform (and the moving playhead) stays visible while comments scroll. Per-comment dots + section strips still work; comment-marker taps still scroll-to-comment via the captured ScrollViewReader proxy. The Title/Version/Cover ApproveBar reposition is still held (24-UX-BACKLOG.md) for a deliberate player-layout design pass. Backlog items struck in 24-UX-BACKLOG.md. BuildProject green.

  • Docs — doc-28 decisions confirmed · doc-27 gotchas folded · player feedback triaged. Owner confirmed doc 28 = Option C (variant enum + bidirectional relations + secondary appearsInProjects[]; not a full Release remodel) — built backend-first, post-polish/sharing (envision-only; variantType not pulled forward). Folded the two live-deploy gotchas into 27-TESTFLIGHT-BACKEND-DEPLOY.md: app stuck runningStatus: Stopped → start via az rest ARM action (the containerapp 1.2.0b4 beta lacks start/stop); ImagePullBackOff → refresh stale ACR admin creds via az containerapp registry set; plus a Squarespace 3-label asuid DNS note. Second round of TestFlight feedback captured in 24-UX-BACKLOG.md and re-grouped into a "Player interaction polish" slice (.agent/next-brief.md): (1) playback resets to the play glyph at end-of-track; (2) comment composer lifts above the keyboard (core-loop blocker — text was hidden); (3) tap a comment → seek to its time; (4) waveform becomes the scrubber (continuous DragGesture, drop the separate Slider) and stays visible while comments scroll. The Title/Version/ Cover ApproveBar reposition is held for a deliberate player-layout design pass (owner's call). The cross-screen accent-color fix moved to "queued behind". 00-ROADMAP.md doc-28 row updated (decisions confirmed).

  • Docs — variants/releases envisioning (28-RECORDINGS-VARIANTS-RELEASES.md) + TestFlight-feedback triage. TestFlight is live with real users syncing. Triaged the owner's first round of feedback. iOS bugs/polish captured in 24-UX-BACKLOG.md and seeded as the next slice (.agent/next-brief.md): (1) play button stuck on pause after a track ends (observe AVPlayerItem.didPlayToEndTimeNotification → reset); (2) accent colors out of sync across screens — root-caused: the same entity is seeded inconsistently (ArtistsHomeView uses versionColor(for: index) vs ArtistDetailView uses versionColor(for: abs(artistId.hashValue)), and Swift String.hashValue is randomized per launch) → fix is one stable-hash Theme.accent(forKey:) used everywhere for identity color, keeping versionColor(for: index) for the V1/V2/V3 palette; (3) cover-selection + version-sheet refine, now unblocked (image upload accepts image/*). New envisioning doc 28 explores modeling remixes / radio-edits / explicit-clean + the same recording appearing on multiple albums/compilations

    • cross-reference navigation. Found the real gaps: Track conflates song-vs-recording (its File[] are production revisions, not variants) and Track.project is single-valued (multi-album membership unrepresentable). Frames the industry Work/Recording/Release model and recommends Option C (pragmatic): a variantType enum + bidirectional relations + secondary appearsInProjects[] on Track, with navigation riding doc-26's "one graph, many lenses" (variant/label/relation as tags + lenses + cross-link chips) — over a full Release remodel (B) or labels-only (A). Envision-now/build-later; 3 decisions to confirm. Doc-27 row updated (API live; TestFlight running); doc-28 added to the 00-ROADMAP.md map.

Fixed

  • Backend — one orphaned track 500'd the whole project list. findAllByProject (the GET /track/project/:id path) resolves every track via Promise.all(findOneById…), and findOneById throws NotFoundException when artistSpace doesn't resolve — so a single corrupt track (the orphan from the POST /track bug above) made the entire project fail to load (Home → Artist → Project). Two-part fix: (1) data repair — a one-off script found the single orphaned test track ("New Client Track") and reset its artistSpace to its project's space; (2) hardeningfindAllByProject now uses Promise.allSettled, dropping + console.warn-ing any track that fails to resolve instead of rejecting the whole batch. (findAllByProjectId shares the same Promise.all pattern — left as-is for now; harden if it ever bites.)
  • Backend — POST /track orphaned new tracks (JSON create from iOS). createTrack took artistSpace from the request body, but the iOS create sheet sends only { name, project, label?, genre? }. With artistSpace undefined, new Types.ObjectId(undefined) mints a random id, so the track saved pointing at a non-existent space. The track's own detail fetch (GET /track/:idfindOneById) then threw NotFoundException on its if (!track.artistSpace || !track.project) guard — surfacing in the app as Sonnance.APIError right after the create sheet ("page didn't load"). Fix: derive artistSpace from the project (projectData.artistSpace?._id ?? projectData.artistSpace), the source of truth — same pattern as owner. Clients now only need to send project; no iOS change required. (Note: any test tracks created before this fix carry a bogus artistSpace and will 404 on detail — they can be ignored or trashed.) (1) CreateCommentDto / CreateResponseDto / UpdateCommentDto (and CreateSectionDto / UpdateSectionDto) had no class-validator decorators, so the global ValidationPipe (whitelist + forbidNonWhitelisted) rejected every field with 400 "property X should not exist". Added proper decorators. (2) SignUrlsInterceptor.signUrlsInObject recursed into Mongoose/bson ObjectId / Date values and collapsed them to {} — corrupting every _id/createdAt in raw-doc responses (the track endpoint escaped this only because it pre-stringifies via serializeTrack). Now leaves ObjectId/Date/Buffer untouched. (3) createCommentByFile returned the unpopulated saved doc (owner as a raw id); now returns the populated + lean shape matching GET /comment/file/:id so clients decode both responses consistently. Also guarded FileService.findById + comment/section file-id queries with isObjectId (null / [] instead of 500 CastErrors). Verified end-to-end: POST 201 + GET 200, string _ids, populated owner.
  • Backend — dead CDN in stored blobUrls. Azure Front Door (sonnance-images…azurefd.net) was deleted, so audio/image URLs 404'd. Disabled CDN_URL in .env and rewrote the 22 stored blobUrls to the direct blob-storage host (sonnancewebappstorage00.blob.core.windows.net); SAS tokens intact. Audio plays.
  • iOS — Swift concurrency in APIClient (Xcode 26 Approachable Concurrency). Six MainActor-isolation warnings (Swift 6 errors) flagged by Xcode where the non-MainActor APIClient actor reaches into APIConfig.baseURL, KeychainStore.accessToken/refreshToken/clear() — all of which were being inferred @MainActor under the project's default isolation. Per-member fix: nonisolated(unsafe) on the static stored + computed properties (URL constant, keychain getters/setters which delegate to thread-safe SecItem* APIs) and nonisolated on the methods (clear(), plus the private read/write/baseQuery helpers). Matches the nonisolated struct MediaFile pattern already in use across the wire-format models. Lint clean; no behavior change.

Added

  • Backend — first live deploy to Azure Container Apps (runbook doc-27 steps 1–3 executed). Image built in ACR (az acr buildsonnanceacr.azurecr.io/ctrl-audio-backend:20260610-170319) and the ctrl-audio-backend Container App (RG Sonnance-WebApp, env sonnance-env) pointed at it. The API is now live on the default host https://ctrl-audio-backend.thankfulsmoke-fb049d11.westeurope.azurecontainerapps.io — verified /api-docs200, /user (unauth) → 401 (routing + guard + Atlas all alive). Env corrected on the running app to match the documented fixes: removed CDN_URL (it still pointed at the deleted Front Door — would 404 all audio/images, same bug fixed locally), added APPLE_CLIENT_ID=com.sonnance.app (primary TestFlight auth, was missing), added JWT_REFRESH_SECRET (generated key, stored as a Container App secret) + JWT_REFRESH_EXPIRATION=30d / JWT_EXPIRATION=30d, added STORAGE_PROVIDER=azure. MONGODB_URI, JWT_SECRET, AZURE_STORAGE_*, Google OAuth secrets were already present from a prior setup; ORIGIN left as-is (already a correct web-CORS list). Two infra gotchas the runbook missed (fold into doc-27): (1) the Container App was in runningStatus: Stopped — every probe returned a fast edge 404 with 0 replicas and no logs; start it with az containerapp start (the locally-installed containerapp extension 1.2.0b4 is a beta that lacks start/stop, so use the ARM action directly: az rest --method post --url .../containerApps/<app>/start?api-version=2024-03-01). (2) After starting, the replica hit ImagePullBackOff — the stored ACR pull password (admin creds, passwordSecretRef, no managed identity) was stale; refresh with az containerapp registry set --server sonnanceacr.azurecr.io --username sonnanceacr --password $(az acr credential show -n sonnanceacr --query passwords[0].value -o tsv). Steps 4–5 done too: custom domain api.sonnance.ff8.dev bound with a free managed cert (SniEnabled, cert mc-sonnance-env-api-sonnance-ff8-9515) — DNS is CNAME api.sonnance → app FQDN + TXT asuid.api.sonnance → <verifyId> on Squarespace-hosted ff8.dev (the editor balks at the 3-label asuid.api.sonnance host but it does save); https://api.sonnance.ff8.dev/api-docs200 over TLS. And the iOS APIConfig.swift release URL was swapped api.sonnance.comapi.sonnance.ff8.dev (debug stays localhost:8080). Remaining: step 6 full-loop smoke test from a TestFlight/release build (Apple Sign In → browse → play → comment → approve → create → upload).
  • Docs — TestFlight backend deploy runbook (27-TESTFLIGHT-BACKEND-DEPLOY.md). TestFlight builds point at https://api.sonnance.ff8.dev (release) not localhost, so the API needs a public host. Confirmed the data tier is already cloud (Atlas mongodb+srv + Azure Blob sonnancewebappstorage00) — only the NestJS process is local, so this is a deploy, not a rearchitecture (CloudKit explicitly rejected, per doc 21). Runbook reuses the existing setup-azure.sh (RG Sonnance-WebApp / ACR sonnanceacr / env sonnance-env / ctrl-audio-backend Container App on :8080) + the Container-App-ready Dockerfile, and fills the gaps: az acr build + containerapp update --image; the current env/secret list (supersedes the stale configure-env.sh — corrects storage vars to AZURE_STORAGE_ACCOUNT_NAME/KEY/CONTAINER_NAME, adds JWT_REFRESH_SECRET, APPLE_CLIENT_ID=com.sonnance.app, APNs); the api.sonnance.ff8.dev custom domain (CNAME + asuid TXT on the ff8.dev zone) with a free managed cert; and the one-line iOS APIConfig release-URL swap. Decision recorded: deploy via bash + az CLI (matches existing scripts); Azure Container App over PaaS; domain on ff8.dev (sonnance.com not yet held). Added doc-27 row to 00-ROADMAP.md map.
  • iOS — Record audio → upload as a version (slice-5 fast-follow, first polish slice of the post-CRUD / pre-TestFlight pass per 24-UX-BACKLOG.md). Slice 5 shipped audio upload via file-pick; recording was explicitly deferred. This adds the record path so a user can capture audio in-app (mic → AAC) and upload it as a new version — completing the upload story. Pure iOS, no backend change — the recorded .m4a flows through the exact same APIClient.uploadFile(...) plumbing as file-pick, including the progress / cancel / error UI on UploadVersionSheet and the post-upload "jump to the new final version" handshake on PlayerViewModel. RecordAudioSheet (Features/Review/) — new glass sheet matching UploadVersionSheet / CreateTrackSheet styling (.thinMaterial, drag indicator, presentationCornerRadius(SnRadius.sheet), .medium/.large detents). Three states plus a permission stage: (a) permission-pending — spinner while AVAudioApplication.requestRecordPermission() is in flight (iOS 17+ async overload); (b) permission-denied — on-brand .snCard with mic.slash glyph and a PrimaryButton "Open Settings" deep-link via UIApplication.openSettingsURLString (the brief's denial-handling requirement); (c) idlemic glyph + "Start recording" PrimaryButton; (d) recording — pulsing amber dot + "RECORDING" caption + huge mm:ss display-font timer driven by a 100 ms Task.sleep loop (not a Timer — keeps Sendable-clean under Approachable Concurrency) + "Stop" PrimaryButton; (e) recorded.snCard with waveform glyph, take duration, accent-tinted play/pause preview button (uses AVAudioPlayer against the local file — simpler than AVPlayer for a single-file playback preview, and no streaming round-trip), then "Use this recording" PrimaryButton
    • "Re-record" GhostButton. AVAudioRecorder.m4a to FileManager.default.temporaryDirectory.appendingPathComponent("sonnance-rec-<UUID>.m4a") with AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 44_100, AVNumberOfChannelsKey: 1, AVEncoderAudioQualityKey: AVAudioQuality.high. Audio session is set to .playAndRecord (.defaultToSpeaker + .allowBluetoothHFP — the new spelling that replaces the iOS-8-deprecated .allowBluetooth raw constant) while the sheet is active; on disappear we restore .playback so the main PlayerView AVPlayer keeps the silent-switch / lock-screen behaviour shipped in slice 1. Re-record stops + deletes the previous temp take before starting a new one (no orphan files left in tmp/). The recording stage is gated against interactive dismissal (interactiveDismissDisabled(phase == .recording)) so a drag-down can't silently drop a take. UploadVersionSheet — the only existing-file touch in this slice: the idle state's "Choose audio file" PrimaryButton now sits above a sibling "Record a new take" GhostButton with a mic.circle glyph; tapping it presents RecordAudioSheet (.sheet(isPresented:) from inside the upload sheet — iOS 26 nests sheets natively, no 250 ms hop needed here because we're not swapping sibling sheets at the same level). On the record sheet's "Use this recording" callback we just set pickedURL / pickedFilename exactly the way the file-importer does, so the existing picked-state + upload-state + error-state flow runs unchanged — zero duplication of progress / cancel / error UI. Filename: "Recording yyyy-MM-dd HH-mm-ss.m4a" (POSIX locale to avoid month-name surprises in non-Latin locales). MIME: handed to the existing AudioMIMEType.from(url:) helper — .m4a resolves to audio/mp4 via UTType, satisfying the backend's audio/* MIME gate (NOT application/octet-stream, which the slice-5 backend gotcha note already calls out). Both entry points from slice 5 still work unchanged — the empty-version "No versions yet" card and the in-player version-switcher header "Add version" button both present the same UploadVersionSheet, which now offers both sources. Info.plist: added NSMicrophoneUsageDescription — without it the system terminates the app on mic access. New file: Sonnance/Features/Review/RecordAudioSheet.swift. Touched: Sonnance/Info.plist, Sonnance/Features/Review/UploadVersionSheet.swift. XcodeRefreshCodeIssuesInFile clean on every touched file; BuildProject green. Out of scope per the brief: waveform-while-recording, trim / edit, multi-take, backend change (the multipart endpoint already accepts audio/*), attachments / artwork. With this slice the upload story is closed — file-pick + record are both shipped and ride the same upload pipe. Self-check vs the rest of the upload surface: typography (display 24 black header, body / caption / display-32-black focal time), spacing tokens, .snCard for grouped content, PrimaryButton / GhostButton actions, accent driven by Theme.versionColor(for:) from the parent — all reused, no raw VStack chrome.
  • iOS — Audio upload (new version) (slice 5 of 25-IOS-CRUD-FIRST-RELEASE.md). Final slice of the first-release CRUD push — the create → review → approve loop now closes fully on-device. A user can pick an audio file from Files / iCloud Drive and upload it as a new version of the track; on success the player reloads and jumps to the new (now-final) version. Recording is deferred as a fast-follow — file-pick is shipped here. JSON-only verbs (slices 2–4) needed no client multipart capability; this slice is where it lands. Net-new in APIClient: uploadFile(path:fileURL:fieldName:filename:mimeType:progress:) — builds the multipart body into a temp file on disk (header + file bytes streamed in 64 KB chunks with FileHandle + footer, so the audio is never loaded into memory wholesale), then uploads with session.upload(for:fromFile:delegate:) and a per-task UploadProgressDelegate that bridges urlSession(_:task:didSendBodyData:totalBytesSent:totalBytesExpectedToSend:) to a Sendable (Double) -> Void progress closure. Bearer auth attached the same as JSON verbs; 401 throws APIError.unauthorized (caller signs out) — deliberately no refresh-then-retry dance here because audio uploads can take minutes and a mid-stream refresh would re-stream the whole body. request.timeoutInterval = 600. ⚠️ Backend gotchas designed around (verified in track.controller.ts L344 + track.service.ts L1017): (1) the part name MUST be exactly file (file[0].mimetype.split('/')[0] has no null guard — wrong part name = 500); (2) the MIME must start with audio/ (or image/ / video/) — anything else returns a BadRequestException body (not a thrown 400), so we surface the message field if present. AudioMIMEType helper (Core/AudioMIMEType.swift) — UTType lookup first (using UTType(filenameExtension:).preferredMIMEType and only accepting audio/*), with an extension fallback (mp3→audio/mpeg, m4a→audio/mp4, wav→audio/wav, aac/flac/aiff/ogg/mp4 likewise). Final fallback is audio/mpegnever application/octet-stream. UploadVersionSheet (Features/Review/) — glass sheet matching CreateTrackSheet / CommentEditSheet styling (.thinMaterial, drag indicator, presentationCornerRadius(SnRadius.sheet), .medium/.large detents). States: idle (choose-audio-file PrimaryButton, tray.and.arrow.up placeholder), picked-not-uploading (filename pill + "Upload" / "Choose different file"), uploading (determinate ProgressView(value:) tinted with the per-version accent, inline % readout, Cancel GhostButton), error (inline caption + Retry implied via the existing buttons). .fileImporter restricted to [.audio, .mp3, .wav, .mpeg4Audio, .aiff]; the security-scoped URL is retained by the sheet, and APIClient.uploadFile opens/closes the scope around the streaming read. Cancel cancels the wrapping Task, which propagates through to URLSessionTask.cancel(). The sheet sets interactiveDismissDisabled(isUploading) so a drag-down can't kill the upload silently, and dismisses itself on success. PlayerViewModel.uploadVersion(fileURL:filename:mimeType:) runs the upload with per-progress @Published-equivalent (@Observable) state (isUploading + uploadProgress (0…1) + uploadError), and on success calls fetchDetail() and then jumps to the new final version (defaultIndex(for: updated)) since the user just uploaded it — keeping fetchDetail's existing "preserve currentIndex" behavior intact for non-upload refresh paths. Errors are split: CancellationError is swallowed silently, APIError.unauthorizedsignOut, APIError.http(_, body) decodes the JSON message field if present (covering the "File is not valid." MIME-gated path), everything else surfaces error.localizedDescription. Entry points (two, both as the brief required): (a) the "No versions yet" empty-state card now leads with an "Add version" PrimaryButton (Edit details demoted to a GhostButton); (b) the in-player version-switcher sheet header gets an "Add version" GhostButton that dismisses the switcher and presents the upload sheet (with a 250 ms hop so SwiftUI doesn't race two .sheet modifiers in the same frame). Auto-notifies collaborators (NEW_AUDIO_VERSION_UPLOAD) is the backend's responsibility — no client action needed. New files: Sonnance/Core/AudioMIMEType.swift, Sonnance/Features/Review/UploadVersionSheet.swift. Touched: Sonnance/Core/APIClient.swift (upload method + UploadProgressDelegate), Sonnance/Features/Review/PlayerView.swift (upload state on the VM + showUploadSheet sheet + entry points). XcodeRefreshCodeIssuesInFile clean on every touched file; BuildProject green. Deferred: recording (AVAudioRecorder-backed record sheet → .m4a) is a fast-follow — file-pick is the must-have shipped here. Out of scope per the brief: attachments (Stems / Lyrics / Contracts — different endpoint), version delete UI (DELETE /:trackId/file/:fileId exists but no UI), image / cover artwork upload (audio-only this slice), waveform regeneration concerns, subscription / version-cap enforcement (commented out server-side).

    After this slice: all five slices of 25-IOS-CRUD-FIRST-RELEASE.md are complete — the full create → review → approve loop runs on-device. Next horizon is the sharing / identity / lens layer (26-IDENTITY-SHARING-NAVIGATION.md, envision-only) and / or web parity (Phase 2). Re-scope before picking — next-brief.md is due for a refresh.

  • iOS — Comment edit / delete / resolve (slice 4 of 25-IOS-CRUD-FIRST-RELEASE.md). Closes the review loop in the player — a user can now resolve/unresolve, edit the text of, and delete comments without leaving PlayerView. JSON-only; no replies / threading (the POST /comment/:id/responses endpoint exists but threading is deferred to a later slice). Actions are wired around CommentCard in PlayerView's commentsList (the card stays presentational): each row gets a .contextMenu and mirrored .swipeActions (.leading resolve toggle in snStatusGreen; .trailing author-only edit in accent + delete in destructive red), so the same set of verbs is reachable via long-press or swipe. Resolve / unresolve is allowed on any visible comment (workflow action — backend even fires a "completed your comment" notification when a non-owner resolves someone's comment). Edit + delete are author-only — gated client-side via comment.owner?.id == AuthManager.shared.currentUser?.id because the backend's update/delete ignore the caller (hardening that with a server-side ownership check is a reasonable future follow-up). Edit sheet: new CommentEditSheet (private to PlayerView.swift) — Sonnance glass sheet matching CreateTrackSheet styling (.thinMaterial background, drag indicator, presentationCornerRadius(SnRadius.sheet), .medium/.large detents); header shows the comment's @m:ss badge in the per-version accent (or "Untimed note"); body is a multi-line pill TextField (lineLimit(3...8)); PrimaryButton Save disabled until the text changes. Delete: destructive .confirmationDialog ("This permanently deletes the comment.") wired via a presenting: binding so the action holds the right comment id even if the dialog races with a list reload — important because DELETE /comment/:id is a hard delete on the backend (no soft-delete / restore, unlike Artist/Project/Track). ⚠️ Backend wire-shape gotcha (verified in comment.service.ts): PUT /comment/:id returns the comment unpopulatedowner is a raw ObjectId string, not the populated { _id, username, … } object that GET /comment/file/:id returns. So the iOS mutators don't try to decode the PUT response as TrackComment (it would type-mismatch on owner: CommentOwner?); instead all three verbs use the existing APIEmptyResponse no-op decoder and reload via loadCommentsAndSections() to get the populated, correct shape (also clean because TrackComment is an immutable let struct — no in-place mutation). New file: none — the CommentEditSheet is private to PlayerView.swift to keep it scoped to the player feature. New model: UpdateCommentRequest in Core/Models/Comments.swift with the same manual omit-nil encode(to:) as TrackCRUDRequests — important here because UpdateCommentDto is a validated class (whitelist + forbidNonWhitelisted), so a null content during a resolve toggle (or vice-versa) would either blank the field or 400. PlayerViewModel additions: mutatingCommentId: String? (per-row in-flight gate — disables a row's swipe/menu and dims it to 50% while its PUT/DELETE is pending so a row can't be acted on twice), plus three async methods: setResolved(_:_:), editComment(_:content:), deleteComment(_:) — each runs the request as APIEmptyResponse, calls await loadCommentsAndSections() on success, and routes 401s through AuthManager.signOut(). PlayerView additions: editingComment: TrackComment? (.sheet(item:) for the edit surface) + pendingDeleteComment: TrackComment? (drives the confirmation dialog) + commentRow(_:) helper composing CommentCard with the menu/swipe/disable. No APIClient change — .put, .delete, and APIEmptyResponse all already exist; no CommentCard change — the resolved checkmark already renders on comment.isResolved == true. XcodeRefreshCodeIssuesInFile clean on every touched file; BuildProject green. Out of scope per the brief: replies / threading, likes, section CRUD, audio upload (slice 5), sharing badges / lenses / Portals-on-Artist-Project (doc 26 envision-only), backend ownership enforcement on edit/delete (future hardening).
  • iOS — Project & Artist CRUD (create / rename / edit / delete) (slice 3 of 25-IOS-CRUD-FIRST-RELEASE.md). The two hierarchy levels above Track from slice 2 — the whole tree can now be built on the phone. JSON-only; cover-image upload still deferred to slice 5. Create artist: ArtistsHomeView gains a + toolbar item presenting CreateArtistSheet — minimal glass sheet, single Name field, POST /artist with name-only body (⚠️ ArtistSpaceDTO is a validated class with whitelist + forbidNonWhitelisted, so any extra key 400s — CreateArtistRequest sends only name). On success the grid reloads and the parent NavigationStack pushes into the new artist via path.append(ArtistRoute(id:)). Create project: ArtistDetailView gains a + toolbar item presenting CreateProjectSheet(artistId:, artistName:) — Name field + segmented Type picker (Single / EP / LP); POST /project with { name, artistSpace, type }. Threads the same $path binding (now propagated through ArtistRoute's destination — ArtistDetailView accepts @Binding var path: NavigationPath) so on success we path.append(ProjectRoute(id, name)) straight into the new project. ⚠️ The backend auto-creates a default empty "Track 01" (name: "", versions: 0) for every new project, so ProjectTracksView's TrackRow now renders "Untitled track" as a display fallback when track.name is empty (no server rename — just the UI). Edit project / artist: overflow Menu (ellipsis) added as a second .topBarTrailing toolbar item — in ProjectTracksView "Edit project" fetches a fresh GET /project/:id and presents EditProjectSheet seeded from those values (name + type), PUT /project/:id with an omit-nil partial; in ArtistDetailView "Rename artist" presents EditArtistSheet, PUT /artist/:id partial. Same manual encode(to:) pattern as TrackCRUDRequests so absent fields aren't sent as null (the backend Object.assign-merges updates, and we don't want to clobber unrelated fields). After a successful project rename, the parent view's local title is updated so the header reflects the change without a hard reload. Delete project / artist: destructive confirmationDialog ("This soft-deletes the project (and its tracks). It can be restored from the web app's Trash." / equivalent for artist) → DELETE /project/:id or DELETE /artist/:id (soft-delete; web Trash handles restore — no iOS restore UI this slice). Reuses the slice-2 didDelete.onChangedismiss() pattern so the navigation stack pops back correctly. New files: Core/Models/ProjectArtistCRUDRequests.swift (CreateProjectRequest + UpdateProjectRequest + CreateArtistRequest + UpdateArtistRequest, all with manual encode(to:) omit-nil); Features/Browse/CreateArtistSheet.swift (hosts both CreateArtistSheet and EditArtistSheet); Features/Browse/CreateProjectSheet.swift (hosts both CreateProjectSheet and EditProjectSheet, plus the ProjectType enum: Single / EP / LP); Features/Browse/CRUDSheetComponents.swift (shared SheetTextField pill + SheetSegmentedField so the four sheets stay visually consistent without bleeding sheet-specific chrome into DesignSystem/). Touched: ArtistsHomeView (new + toolbar item + path threaded into ArtistRoute destination); ArtistDetailView (now takes @Binding var path, gains + and ellipsis menu, hosts create-project / rename / delete + soft-delete view-model state); ProjectTracksView (gains ellipsis menu beside the existing +, edit + delete view-model state, the empty-name TrackRow fallback, local project-name shadow so post-rename header updates). APIEmptyResponse reused for the PUT/DELETE responses; APIClient.Method already had .put + .delete. Build green; XcodeRefreshCodeIssuesInFile clean on every touched file. Out of scope per the brief: cover-image / artwork upload (slice 5), Track CRUD changes (slice 2 shipped), comment edit / delete / resolve (slice 4), collaborators editing, sharing / badges / lenses, Portals-on-Artist/Project (doc 26 envision-only), restore / trash UI.
  • iOS — Track CRUD (create / rename / edit metadata / delete) (slice 2 of 25-IOS-CRUD-FIRST-RELEASE.md). A user can now make and manage tracks on the phone — JSON-only, no audio upload yet (slice 5). Create: ProjectTracksView gains a + toolbar button (and a PrimaryButton in the empty state) presenting CreateTrackSheet — minimal glass-bg sheet with Name (required) / Label / Genre fields composed from DesignSystem/ (no stock Form). POST /track is sent with { name, project, label?, genre? }; on success the project list reloads and the parent NavigationStack pushes straight into the new track's PlayerView(trackId:) via a shared NavigationPath (ArtistsHomeView now owns @State path = NavigationPath() and threads $path down through the ProjectRoute destination). Rename / edit metadata: the Track Portal (TrackPortalContent) gains an Edit affordance — the existing read-only metadata block flips four rows (Name, Label, Genre, Release date) into TextFields + a toggleable DatePicker, with a glass Save / Cancel pair in the header. On Save, PUT /track/:id is sent as a partial body (only changed fields — both CreateTrackRequest and UpdateTrackRequest implement a manual encode(to:) so nil is omitted, not emitted as null); the player refetches the populated detail to refresh the Portal (fetchDetail now preserves currentIndex on refresh — only the first fetch picks the default version, so editing metadata doesn't jump the player back to "final"). Delete: a destructive button at the bottom of the Portal triggers a confirmationDialog ("This soft-deletes the track. It can be restored from the web app's Trash."), calls DELETE /track/:id, sets viewModel.didDelete = true; PlayerView observes that via .onChange and dismisses, popping back to the project. Empty-version player state: freshly-created tracks (audio: []) render a graceful "No versions yet" .snCard with a PrimaryButton opening the Portal — the bottom glass stack (PlayerBar / ApproveBar / CommentComposer) is suppressed so an audio-less track doesn't pretend to be playable. New: Core/Models/TrackCRUDRequests.swift (CreateTrackRequest + UpdateTrackRequest); Core/Models/APIEmptyResponse.swift (permissive no-op Decodable for endpoints whose response body we don't need to read); Features/Browse/CreateTrackSheet.swift. APIClient.Method gains .put. TrackDetail extended with permissive label / genre / releaseDate (ISO8601 String, parsed via releaseDateValue: Date?). Build green; XcodeRefreshCodeIssuesInFile clean on every touched file. Out of scope per the brief: audio upload, Project / Artist CRUD, comment edit / delete / resolve, sharing badges or lenses, restore / trash UI.
  • Docs — identity / sharing / navigation envisioning (26-IDENTITY-SHARING-NAVIGATION.md). Synthesizes the owner's vision (Workspace that holds one-or-many Rosters; outbound sharing; recipient-arrangeable inbound shares with label/dot/bubble/color/team-avatar identity down to the version; Portals on every element; and many ways to reach a track-version — hierarchy, role, internal/external, share-direction, #tags, custom views) against the real actors (doc 12) and the real backend. Backbone principle: "one graph, many lenses" — every navigation axis is a saved filter over one object graph, with the invariant lenses filter, roles gate. Documents the proposed Workspace → Roster(s) → ArtistSpace → … container model (Roster collapses to a single default for solo users), the access-lens table mapped to actors, the two-part sharing identity (sharer-side fixed vs recipient-side arrangeable), Portals-as-universal-inspector, and a reuse-vs- net-new backend map (no Workspace/Roster/Team/Tag/internal-external concepts exist today; SharedResource = Space/Project/Track only). Ends with 3 decisions to confirm (Roster weight · scope/sequencing · version sharing granularity). Nothing built yet — CRUD slices 2–5 (doc 25) proceed independently. 00-ROADMAP.md doc map + doc 25 open-question updated to point here. Decisions confirmed 2026-06-07: (1) Roster = a label/color on ArtistSpaces, not a new container (no hierarchy change); (2) envision-only — build CRUD slices 2–5 next, sharing/lens system after; (3) true per-version sharingSharedResource enum + link extend to File granularity (built with the sharing layer, post-CRUD). next-brief.md advanced to slice 2 (Track CRUD).
  • iOS — collapsed the version page → straight-to-player + inline version switcher (slice 1 of 25-IOS-CRUD-FIRST-RELEASE.md). Tapping a track now opens PlayerView directly from every entry point (Recents, Browse → Artist → Project → Tracks). PlayerView gains a second initializer PlayerView(trackId: String) that fetches GET /track/:id itself (logic previously in TrackDetailViewModel) and defaults the active version to the final (audioFinal id match, then isFinalVersion == true) else the last entry in audio. The historic PlayerView(detail:startIndex:) init is kept working internally; on-brand loading/error states (ProgressView tinted .snForeground; exclamationmark.triangle block) cover the fetch. Inline version switcher: the "V# {name}" line in the player is now a Button with a trailing chevron.down that opens a glass sheet (.presentationDetents([.medium, .large]), .thinMaterial, drag indicator + corner radius matching Portal) listing all versions as VersionCards — selecting one calls the existing selectVersion(at:) so audio / waveform / comments / sections reload exactly as the old rail did. The version-card rail inside PlayerView is gone. The Portals button on PlayerBar now re-presents TrackPortalContent for the player's loaded detail (no behavior change). Retired: Features/Review/TrackDetailView.swift deleted (nothing references it; TrackPortalContent / VersionCard / ArtworkHero kept — reused). navigationDestination(for: String.self) updated in ArtistsHomeView + RecentsView to mount PlayerView(trackId:). UX-backlog "version switching via the track-name line" item struck. Build green; XcodeRefreshCodeIssuesInFile clean on every touched file.
  • iOS — Shared tab pulled (re-scope, not a feature). Per 25-IOS-CRUD-FIRST-RELEASE.md, the inbound Shared surface is parked pending a Roster / workspaces / actors decision — keeping it in the tab bar implied a feature commitment that hasn't been made. RootTabsView is back to two tabs (Home / Recents). Deleted Features/Shared/SharedView.swift, the now-empty Features/Shared/ group, and Core/Models/SharedItems.swift (the SharedProject / SharedTrack wrappers had no other consumers). Backend /collaboration/* endpoints untouched — only the iOS surface is pulled, so re-introducing later is a view-layer add. Build green.
  • Docs — first-release scope upgrade + Shared course-correction (25-IOS-CRUD-FIRST-RELEASE.md). Review feedback established that a review-only app isn't a shippable first release — first release now targets the create → review → approve loop. Verified against the controllers that the backend already exposes full CRUD for the whole hierarchy (Artist/Project/Track/Comment/Section create-update-delete + restore; the only gap is uploading a new audio version from the device). New doc 25 scopes + sequences the iOS CRUD work (slices: 1 inline version-switch → 2 Track CRUD → 3 Project/Artist CRUD → 4 comment edit/delete/resolve → 5 staged audio upload). Two course-corrections recorded: (a) Shared pulled — the inbound Shared tab was premature ("Shared" depends on the undecided Roster/workspaces/actors model); it's parked pending an actors-grounded decision (doc 23 Sharing section + doc 24 marked parked). (b) Version page removed — the version-card rail between Track and the player is redundant; version switching moves into the player. 00-ROADMAP.md §5 + doc map updated. Also adopted a file-based agent handoff (.agent/next-brief.md) so slice briefs live on disk, not in chat — seeded with slice 1 (collapse version page + pull Shared tab).
  • iOS — Shared (inbound) screen. Build-order item 4 from 23-IOS-PHASE2-IA.md — the "Shared with me" surface. New Features/Shared/SharedView.swift: fans out three inbound endpoints in parallel (GET /collaboration/collaborator/{spaces,projects,tracks}), renders sectioned artwork-forward groups — circular ArtworkHero tiles for Artists, 2-col ProjectCard grid for Projects, LazyVStack of TrackRows for Tracks. Per-feed failures tolerated independently; a 401 from any source signs out. Taps reuse the Phase 1 / Phase 2 destinations (ArtistRoute → ArtistDetailView, ProjectRoute → ProjectTracksView, String → TrackDetailView) — no Phase 1 changes. On-brand empty state ("Nothing shared with you yet" + tray.and.arrow.down). New Codable wrappers in Core/Models/SharedItems.swift: SharedProject ({ project: ProjectSummary, role: String? }) and SharedTrack ({ track: Track, role: String? }); spaces decode straight into the existing ArtistSpace Codable. RootTabsView is now three tabs — Home / Shared / Recents. Outbound ("Shared by me") deliberately out of scope (waits on the /collaboration/shared-by-me backend aggregate). Build green; touched files lint-clean.
  • iOS — Player polish pass (three [quick] UX-backlog items). Targeted edits in PlayerBar, PlayerView, VersionCard — no data-flow or Phase 2 changes. (1) Portals icon swapped to Image(systemName: "rectangle.righthalf.inset.filled") to match the web's side-panel affordance. (2) Play-over-cover — the circular cover thumbnail in PlayerBar is now the play/pause control: artwork Button wraps a ZStack { LinearGradient circle → .ultraThinMaterial disc → play.fill/pause.fill glyph in snForeground } with a small optical-centering offset for the play triangle, so the glyph reads on any cover while keeping the artwork the focal element. The separate play/pause button is removed. (3) "V#" bubblecurrentVersionLabel in PlayerView now renders the version number in a Capsule().fill(accent.opacity(0.18)) with accent-color text, separated from the filename (display regular). VersionCard mirrors the same pattern beneath the accent underline. Items struck in 24-UX-BACKLOG.md. Build green; touched files lint-clean.
  • 24-UX-BACKLOG.md — running list of iOS UX/polish refinements (tagged [quick] / [P2] / [P3]) so review feedback isn't lost in chat: play-button-over-cover, Portals icon (rectangle.righthalf.inset.filled), "V#" bubble, version-switch-via-name, cover selection at Portal level, pinned/PCM waveform, clearer Approve affordance. Naming locked: future grouping above ArtistSpace = "Roster" (not "Label"); doc 23 updated.
  • iOS — Phase 2 browse hierarchy (Artists → Projects → Tracks). RootTabsView makes ArtistsHomeView the primary signed-in surface and demotes Recents to a sibling tab — matches the 23-IOS-PHASE2-IA.md IA. New Features/Browse/ directory: ArtistsHomeView (GET /artist → 2-col LazyVGrid of circular ArtworkHero tiles + display name, profile menu in toolbar, owns navigationDestination for ArtistRoute + ProjectRoute + String trackId so the whole chain reuses one stack), ArtistDetailView (parallel GET /artist/:id + GET /project/artist/:artistId; circular hero + display name + "Artist" subtitle + 2-col ProjectCard grid — matches mobile-front--artis-projects.png), ProjectTracksView (GET /track/project/:projectIdLazyVStack of existing TrackRow, taps push String (trackId) so Phase 1 TrackDetailView / PlayerView chain runs unchanged). New Codable models: ArtistSpace + ProjectSummary (both nonisolated, permissive — only id + name required, nonEmptyImage helper for backend's "" defaults). ContentView now routes signed-in users to RootTabsView instead of straight to RecentsView. Phase 1 player/comment/approve surfaces deliberately not touched. Build green; touched files lint-clean.
  • 23-IOS-PHASE2-IA.md — Phase 2 spec: the Artist→Project→Track artwork-forward browse hierarchy (replacing the flat Recents feed), shared inbound/outbound, and Portals on Artist/Project. Grounded in real endpoints (GET /artist, /project/artist/:id, /track/project/:id, /collaboration/collaborator/*); flags that "Label" is not a backend entity and the "shared-by-me" aggregate is a backend gap. Phase 1 review loop stays unchanged.
  • iOS — Waveform tap-to-seek + defensive fileId guard. Waveform gains an onSeek: ((Double) -> Void)? callback (normalized 0…1 position) wired to viewModel.seek(to: fraction * duration) so tapping anywhere on the waveform jumps the player to that region — a coarse companion to the precision Slider underneath. Implemented via .contentShape(Rectangle()) + .onTapGesture(coordinateSpace: .local) { CGPoint }; CommentMarker buttons retain tap priority within their hit target so dot taps still go to onTapComment. Separately, PlayerViewModel.loadCommentsAndSections() and postComment() now early-return when currentFile.id is empty or "0" — defensive while that origin is still being chased on the backend (which now returns a clean 400 for these).
  • iOS — Comments-at-timestamp + ApproveBar slice. Player gains the full review loop. Waveform overlays per-comment 6pt accent dots at timeStart/duration × width (tap → ScrollViewReader.scrollTo(commentId, anchor: .center) + 1.8s accent ring on the matching CommentCard) and per-section colored capsule strips along the bottom using each TrackSection.color (new Color(hex:) parser tolerates malformed input). CommentCard (initials avatar in per-version accent, display name from usernamefullName fallback, sonnanceTimecode badge for timestamped comments, resolved checkmark) and CommentComposer (current player position chip pre-fills timeStart, glass-wrapped text field, async send) are new DesignSystem components. ApproveBar — three pills (Title · Version · Cover) coloured by SnStatus.from(backendReleaseStatus:); tap → confirmationDialog with the four backend statuses; PATCH /track/:id/release-status then prefers the server-returned releaseStatus and falls back to a local mutation if absent. PlayerView mounts the three glass widgets stacked via .safeAreaInset(edge: .bottom). New Codable models: TrackComment + CommentOwner + TrackSection + CreateCommentRequest (Core/Models/Comments.swift); ReleaseStatus + ReleaseDimension + TrackReleaseStatus + UpdateReleaseStatusRequest + permissive TrackReleaseStatusResponse (Core/Models/ReleaseStatus.swift). TrackDetail.releaseStatus switched to var so the ApproveBar can mutate without re-decoding the whole track. TrackComment.responses decoded tolerantly (backend doesn't populate at /comment/file/:id). Build green; touched files lint-clean.
  • iOS — Player slice (AVPlayer + focal glass PlayerBar). PlayerView presented via NavigationLink from each VersionCard in TrackDetailView (same stack, swipe-back, TrackDetail + start index passed in — no re-fetch). PlayerViewModel (@MainActor @Observable) owns an AVPlayer, sets AVAudioSession.playback so the silent switch doesn't mute, uses addPeriodicTimeObserver (0.1s) for position, asset.load(.duration) (async) for total, suppresses observer writes while scrubbing. Composition matches web-front--wave-player.png: display title, interactive VersionCard rail (tap to switch versions, selected gets a per-version-color ring), centered "V{n}. {name}" display line, big focal 00:00 — 03:45 timecode (sonnanceDisplay + .monospacedDigit()), Waveform (new — deterministic sine-bar placeholder, played portion in accent) + Slider scrubber. The focal Liquid Glass surface per spec §5 — PlayerBar — sits in .safeAreaInset(edge: .bottom) with circular artwork, tail-truncated title block, mini-timecode (sonnanceTimecode), play/pause, and a Portals button that re-presents the metadata sheet. TrackPortalContent extracted to Features/Review/ so the same Portal body powers both TrackDetailView and PlayerView. New DesignSystem components: PlayerBar, Waveform. Build green; touched files lint-clean.
  • iOS — DesignSystem/ (Swift) + screen retrofits. Native implementation of 22-IOS-DESIGN-SYSTEM.md — every product surface now composes from these. Theme (semantic Color.sn* via trait-adaptive UIColor — no asset catalog needed, light/dark automatic) with status palette (SnStatus.{noStatus,preProduction,underReview,approved,notApproved}) and per-version palette (Theme.versionColor(for:), 8-color wrap). ThemeManager (@Observable, SnAppearance.{system,light,dark}, persisted to UserDefaults, applied at root via .preferredColorScheme). Typography helpers (.sonnanceDisplay, .sonnanceTitle, .sonnanceBody, .sonnanceCaption, .sonnanceTimecode). Tokens (SnSpacing, SnRadius). Surfaces: ScreenBackground(accent:), AdaptiveGradient (+ a working AdaptiveColor.average(from:) helper for future image-driven halos), GlassSurface / .glassCard() / .snCard() — glass honors Reduce Transparency. Controls: PrimaryButton, GhostButton, AppearancePicker. Components: SectionHeader, StatusPill, TrackRow, ProjectCard, ArtworkHero (circle/rounded-square), VersionCard (vinyl-disc motif + version-color underline), MetadataRow, LabelCopyTable, Portal + PortalHeader (.sheet w/ .presentationDetents([.medium, .large]) + glass bg + drag indicator). Screen retrofits: SignInWithAppleView (display SONNANCE wordmark + tagline + Apple button), RecentsView (sectioned feed w/ TrackRows + on-brand empty/error states + AppearancePicker in toolbar menu), TrackDetailView (ArtworkHero + adaptive gradient halo + display title + horizontal VersionCard rail + a "Portals" toolbar button presenting metadata + LabelCopyTable in a Portal). PPMonumentExtended-Black/Regular/Light .otf copied to Sonnance/Sonnance/Resources/Fonts/ and registered in Info.plist UIAppFonts. Build green; touched screens lint-clean.
  • iOS design system foundation (docs + workflow). 22-IOS-DESIGN-SYSTEM.md — the iOS design source of truth: light/dark/system theming, semantic color tokens, per-version color palette, per-status pills, typography (PPMonumentExtended + Lexend), Liquid Glass usage, adaptive gradient, the Portal pattern (glass sheet w/ detents), and a component inventory — grounded in ctrl-audio-front/docs/canva-refs/. Added .agent/workflows/ios-feature-slice.md (slice checklist + Definition of Done) and a prominent "build on the design system" rule in ctrl-audio-ios/CLAUDE.md. (Swift DesignSystem/ + screen retrofits follow in the Xcode agent.)
  • iOS — landing feed + Track Detail slice. RecentsView now fans out to GET /track/recent-access, GET /track/shared, and GET /track in parallel and renders three sectioned groups (Recent / Shared with me / Your tracks), de-duplicated in priority order so a new user's feed isn't empty. Per-endpoint failures are tolerated independently; a 401 from any source signs the user out. New TrackDetailView reachable via NavigationLink(value: trackId) + navigationDestination(for: String.self) — calls GET /track/:id, lists audio[] versions with a "Final" badge (driven by MediaFile.isFinalVersion or audioFinal match). New Codable models: MediaFile (mirrors backend File; blobUrl arrives pre-signed via SignUrlsInterceptor) and TrackDetail (populated shape from findOneById, kept separate from list-row Track since list endpoints return audio as ObjectId strings). Static — no player yet.
  • iOS — walking skeleton (auth + Recents). First functional slice of the iOS app: APIClient (URLSession actor, bearer auth, single-flight 401 → POST /user/refresh-token → one-shot retry), KeychainStore (access + refresh tokens, after-first-unlock), AuthManager (@Observable, restores session via GET /user/detail on launch), SignInWithAppleView (ASAuthorizationPOST /user/auth/apple), RecentsView (GET /track/recent-access). Auth-gated root in ContentView. Codable models: User, AuthResponse, RefreshResponse, Track (minimal — controller serializer is loose; expand as detail screens land). Project builds clean.
  • Backend + iOS — SIWA nonce verification. Backend verifyAppleToken now accepts the raw nonce from the client, SHA-256-hashes it, and passes it to appleSignin.verifyIdToken (replay-attack prevention). AppleLoginDto carries an optional nonce field (backward-compatible — no nonce = check skipped). iOS SignInWithAppleView generates a 32-byte random nonce per request, sets request.nonce = SHA256(rawNonce) via CryptoKit, and forwards the raw nonce in AppleLoginRequest. AuthManager.signInWithApple and AuthModels.AppleLoginRequest updated accordingly.
  • Backend — scripts/seed-demo-data.ts (fixed + hardened). Creates Demo Space → Seed Session → 3 tracks and uploads real audio from infrastructure/testAssets/. Switched to Node 18+ globals (fetch/FormData/Blob) — fixes form_data_1.default is not a constructor (no esModuleInterop). Now idempotent and resumable (uploads audio only to tracks missing it); verified end-to-end. Run: npx ts-node scripts/seed-demo-data.ts yo@ff8.dev (backend running).
  • 21-DATA-ARCHITECTURE-AND-IDENTITY.md — data source-of-record (server, not CloudKit), data-ownership ladder (hold-all → export → BYO storage → self-host), and the Apple/bundle-id scheme.
  • iOS app (ctrl-audio-ios) — native SwiftUI project (iOS 26, Liquid Glass) created in Xcode + agent scaffolding (CLAUDE.md, README, mirrored .claude settings), wired into the workspace; GitHub repo + git version control initialized.
  • Backend — APNs pushPOST /notification/register-device + APNs sender (apns2) auto-fired from every notification (best-effort, no-op until APNS_* env set).
  • Backend — refresh tokensPOST /user/refresh-token; refreshToken added to all auth responses (existing token unchanged, web-safe).
  • Backend — Sign in with ApplePOST /user/auth/apple (native identity-token verify → app JWT) via apple-signin-auth.
  • Backend — track approval workflowPATCH /track/:id/release-status (approve / request changes), TrackReleaseStatus.APPROVED, TRACK_RELEASE_STATUS_UPDATE notification event.
  • AI-native workspace — root + per-repo CLAUDE.md, AGENTS.md, 00-ROADMAP.md (product source of truth), 19-IOS-MVP-REVIEW-APPROVE.md, 20-UI-SIMPLIFICATION.md; agent rooting policy + cross-agent coordination via this changelog.
  • AZURE-RESOURCES.md — dedicated Azure resource inventory
  • CDN-FRONTDOOR.md — CDN/Front Door architecture documentation
  • CHANGELOG.md — this file
  • .agent/workflows/changelog.md — workflow rule for consistent changelog maintenance
  • TECHNICAL-DEBT.md — centralized technical debt catalog
  • RBAC-DESIGN.md — team/permissions planning document

Known gaps (iOS design system)

  • PPMonumentExtended .otf needs to be added to the Xcode target. Files live at Sonnance/Sonnance/Resources/Fonts/ but MCP can't mutate the resources build phase — drag the Resources folder into Xcode and tick the Sonnance target so the .otf bundle. Until then, Font.custom("PPMonumentExtended-Black") silently falls back to SF Pro.
  • Lexend .ttf is not bundled (not in workspace; web pulls from Google Fonts at runtime). Typography helpers reference Lexend-Bold/Regular/Light so they pick up automatically when the .ttf files are dropped into Resources/Fonts/ and added to the target. Body/caption fall back to SF Pro until then.

Changed

  • iOS — stripped Xcode template's SwiftData + CloudKit. Removed Item.swift, the ModelContainer plumbing in SonnanceApp.swift, the iCloud entitlement keys, and the SwiftData query in ContentView.swift. The backend (MongoDB + Azure Blob) is the source of record; any future local persistence is cache-only (per 21-DATA-ARCHITECTURE-AND-IDENTITY.md). The iCloud capability still needs to be toggled off in Xcode > Signing & Capabilities to prevent re-add.
  • Branding standardized to Sonnance (legacy aliases: Ctrl Audio, WAVIC).
  • Platform strategy → iOS-first (native Swift/SwiftUI, Liquid Glass); web app to follow to parity. One app / one dashboard / role-driven content — per-user-type homes deferred to a post-MVP customization layer.

[2026-02-21] — Sidebar Frosted Glass & ArtistSpace Serialization

Fixed

  • Sidebar frosted glass effect (Desktop): Moved SidebarExpandable outside the transformed <aside> (as a Fragment sibling) — CSS spec requires position:fixed elements to not be inside transformed ancestors for backdrop-filter to work against the viewport. Added clip-path:inset(0) to prevent WebKit blur edge bleeding.
  • Sidebar frosted glass effect (Mobile): Unified mobile drawer styling with same bg-gray-0/90 / dark:bg-gray-100/80 background and explicit WebkitBackdropFilter inline styles to match Profile Menu appearance.
  • ArtistSpace serialization in project controller: Fixed findAll and findAllByArtist endpoints that called .toString() on potentially populated artistSpace objects, which would return [object Object] and destroy artist data (name, image). Now uses the same defensive typeof === 'object' check as findById.
  • scripts/migrate-s3url-to-bloburl.js — database migration script
  • Sentry integration token for error monitoring setup

Changed

  • BREAKING: Renamed s3UrlblobUrl across backend (6 files) and frontend (26 files) — requires DB migration
  • Updated API-DESIGN.md with shortId/slug URL identifier system
  • Updated DATA-MODEL.md with shortId/slug fields and blobUrl rename
  • Reorganized infrastructure docs: numbered 01-14, replaced CI/CD-only README with proper index, fixed cross-references
  • Moved 04-SCALABILITY.md from backend to infrastructure as 14-SCALABILITY.md
  • Removed SENTRY_PERSONALTOKEN.md (secret — should not be in docs)
  • Updated PLATFORM-VISION.md to reflect current infrastructure state
  • Fixed frontend doc numbering (10-FEATURES-ROADMAP.md15-FEATURES-ROADMAP.md)
  • Fixed corrupted table in Features Roadmap
  • Updated feature roadmap infrastructure checkboxes (Container Apps ✅, CDN ✅, Blob Storage ✅)
  • Updated frontend and backend doc READMEs with product name TBD note

Removed

  • Cosmos DB references from active planning (decision: stay with MongoDB Atlas)

[2026-02-15] — Console Cleanup, Error Handling & Skeleton Polish

Fixed

  • Console.log cleanup: Removed ~30 debug console.logs from core navigation flow (dashboard, project, artist headers, wave-form, socket provider, url-helpers, upload modals, track columns)
  • Image src errors: Fixed empty string passed to Next.js <Image> in wave-header, sidebar, search columns/mobile-drawer; made AvatarCard.src optional
  • WaveSurfer AbortError: Added proper AbortError suppression (unhandledrejection listener + .on('error') filter) to all three wave-form components (wave-form.tsx, flash/wave-form.tsx, project-wave.tsx)
  • Nested <button> hydration error: Fixed <PopoverButton> wrapping <ActionIcon> in new-track-columns.tsx by using as="div" on PopoverButton
  • Uncontrolled-to-controlled warning: Suppressed benign Headless UI Select warning in desktop-player.tsx via recharts-console-error.ts filter
  • next-auth debug warning: Disabled NextAuth debug mode in auth-options.ts
  • Font preload warnings: Set preload: false on PPMonumentExtended local font (only used on specific pages)
  • Backend addRecentAccess 500: Added ObjectId validation, try-catch, and optional chaining in user.service.ts

Changed

  • Loading skeletons enhanced for artist and project views with improved layout consistency
  • Skeleton shimmer effect unified across components
  • MainComponent layout simplified

[2026-02-06] — Production Bug Fix

Fixed

  • Resolved 500 errors on project track access caused by BSONError in identifier resolution
  • TrackController and TrackService now correctly handle shortId/slug identifiers

[2026-02-04] — Performance & CDN

Added

  • Azure Front Door CDN for image and audio delivery (sonnance-cdn profile)
  • setup-cdn.sh infrastructure script
  • AzureImage frontend component for CDN-aware image loading
  • ReactQuery caching strategy (staleTime: 5min, gcTime: 30min)

Changed

  • Image processing pipeline: Sharp generates thumb/medium/original WebP variants
  • Backend CDN_URL env var set on prod and dev Container Apps

[2026-01-27] — URL Identifier System

Added

  • ShortId system using nanoid (6-char alphanumeric codes)
  • Slug support for premium/user-customized URLs
  • findByIdentifier() resolution: slug → shortId → ObjectId
  • Database migration script for existing entities

[2026-01-XX] — Initial Deployment

Added

  • Azure Container Apps backend (prod + dev)
  • Azure Static Web Apps frontend (prod + dev)
  • Azure Container Registry (sonnanceacr)
  • GitHub Actions CI/CD workflows (deploy.yml, mirror-to-azure.yml)
  • MongoDB Atlas integration
  • Google OAuth authentication
  • Stripe payment integration (test mode)

Ctrl-Audio Platform Documentation