Skip to content

26 — Identity, Sharing & Navigation (envisioning)

Envisioning doc — needs confirmation. Governed by 00-ROADMAP.md. Created 2026-06-07. Answers the open question parked in 25-IOS-CRUD-FIRST-RELEASE.md and supersedes the parked Sharing section of 23-IOS-PHASE2-IA.md once locked. Grounded in the real actors (12-ACTORS-WORKFLOWS.md) and the real backend. Nothing here is built yet — this is the model we agree on before building. Decisions to confirm are at the end.

What triggered this

A first-pass Shared tab was built and pulled — "Shared" isn't a tab, it's a consequence of how the account is structured. The owner's framing: a Workspace that may hold one or many Rosters; elements shareable outbound; inbound shares the recipient can organize (label / dot / bubble / color / team avatars); sharing visible down to the version; the Portal (metadata) surface on every element; and — because actor preference varies by role, culture, and generation — many ways to reach the same track-version: by hierarchy, by ownership/collaboration/viewer, by internal/external, by share direction (inbound/outbound), by #tags, and by custom filters / saved views.

Backend reality today (verified 2026-06-07)

  • Hierarchy: ArtistSpace → Project → Track → File (version) → Comment / Section. ArtistSpace is the top.
  • Sharing = collaborators (inline { userId, role } arrays on ArtistSpace/Project/Track; Role = owner/admin/editor/viewer; cascades down) + links (link entity: per-resource share URLs with expiry/TTL/max-uses). Shareable granularity is the SharedResource enum: ArtistSpace | Project | Track.
  • No Workspace, no Roster, no Team, no Tag entities. No "internal vs external" concept. These are net-new.

The backbone principle: one graph, many lenses

There is one object graph (the hierarchy above). Every way the user wants to "get to a track-version" is a lens — a saved filter over that one graph — not a separate copy or screen. The hierarchy is just the default lens. This is what makes the owner's long list of navigation axes cheap: each axis is a facet, not a feature rebuild. It's also how we honor per-actor preference without per-actor homes (consistent with the one-dashboard decision in 20) — the actor picks a default lens; the surface stays one surface.

Hard invariant: lenses filter; roles gate. A lens never grants access — it only reorders/filters what the user can already see via their collaborator role. Faceting must never become a permissions backdoor.


Layer 1 — Container model (the structural axis) — confirmed: lightweight

No new container entity above ArtistSpace. The hierarchy stays ArtistSpace → Project → Track → File(version). (Confirmed 2026-06-07.)

  • "Workspace" stays conceptual — it's everything you own (all your ArtistSpaces + what's shared to you), not a stored entity. It's the mental boundary for "internal", not a schema layer.
  • Roster = a label + color on ArtistSpaces — a soft grouping facet, not structure. A manager tags artists into colored rosters ("Pop", "Urban"); a solo artist never sets one and never sees the concept. Implement as a small field on ArtistSpace (e.g. roster: { name, color } or reuse the tags layer) — no migration of the hierarchy. Rosters then become a lens (group/filter by roster) like any other facet in Layer 2.

This is the lightest path that still satisfies "one-or-many rosters": grouping is achieved by tagging, so there's nothing to collapse for solo users and nothing new to model server-side beyond a field.

Layer 2 — Access lenses (the navigation axis)

All over the same graph; combinable; each maps to how a real actor works:

LensWhat it filters byPrimary actor fit
Hierarchy (default)Workspace → Roster → Artist → Project → Track → Versioneveryone; artists/producers
Roleownership vs collaboration vs viewer-onlymanagers ("what I own"), guests
Internal / externalcollaborators inside vs outside the Workspacemanagers, A&R, legal
Share directioninbound ("shared with me") vs outbound ("I shared")everyone — replaces the pulled tab
Statusrelease status (no-status / in-progress / under-review / approved)artists ("awaiting my approval")
#Tagsfree tags on any nodepower users; cross-project themes (e.g. #mixing, #single)
Saved viewsa pinned combination of the abovethe "custom views" ask; per-actor defaults

Per-actor defaults (illustrative, user-overridable): Artist → Status: awaiting my approval + hierarchy · Producer/Engineer → outbound + needs-revision · Manager/A&R → cross-roster under-review + activity. These are defaults of one shared surface, not different apps.

Layer 3 — Sharing identity (the visual/semantic axis)

Two distinct things, both surfaced as badges (color / dot / bubble / avatars):

  • Sharer-side identity (fixed): who shared this and as part of what — the collaborator/team avatars, the source roster/workspace. Answers "who gave me this?"
  • Recipient-side organization (arrangeable): your own label + color on an inbound share, set by you, independent of how the sharer filed it (Gmail-labels / Finder-tags model). Answers "how did I file this?"

Surfaced on cards, rows, and version pills — down to the version — and editable in that element's Portal. Confirmed: a single version can be shared independently (you can send just v3) — so the backend SharedResource enum and the link entity extend to File/version granularity (see backend map). "Internal vs external" is itself a badge: since there's no Workspace entity, internal = a registered user you've added as a collaborator; external = guest/link access or someone outside your collaborator set (working definition — revisit if a real org/team entity lands).

Layer 4 — Portals everywhere (the universal inspector)

Generalize the Phase-1 Track Portal into the metadata + collaborators + share + identity surface for every node — Workspace, Roster, Artist, Project, Track, and Version. Same Portal / MetadataRow / LabelCopyTable components (already in DesignSystem/), node-specific content bodies. The Portal is where you see who it's shared with, set the recipient-side label/color, manage links, and edit metadata — uniformly.


How this maps to the backend (what's reuse vs net-new)

CapabilityTodayNet-new work
Hierarchy nav✅ ArtistSpace→…→Versionnone — no container added (Roster is a field, below)
Roster groupinga roster {name,color} field on ArtistSpace + a group/filter lens (no hierarchy change)
Role lens✅ collaborator role arraysclient-side filter only
Share-direction lens🟡 inbound via /collaboration/collaborator/*; outbound derivableoptional /collaboration/shared-by-me aggregate
Internal/external❌ no conceptderive from collaborator-vs-guest (working def above); no entity needed
Tagsshipped 2026-06-18tags[] + shareTags on Artist/Project/Track, owner-private + opt-in, GET /search/tags aggregate, read-privacy interceptordone (node tags); Comment.tags[] still deferred to doc-33 notes
Saved viewsa small per-user "views" store (can start client-only)
Sharing identity badgesrecipient-side label/color store; render badges on cards/rows/version pills
Portals everywhere🟡 Track onlycontent bodies for Roster/Artist/Project/Version
Version-level sharing❌ (SharedResource = Space/Project/Track)confirmed target — extend SharedResource enum + link to File; collaborator/link resolution at file granularity (built when the sharing layer is built, post-CRUD)

None of this blocks the CRUD slices (2–5 in doc 25) — Track/Project/Artist CRUD and audio upload are independent and can proceed in parallel while this model is refined.

Additions beyond the brief (proposed)

  1. Saved views as first-class — make the "custom views" ask concrete: a pinned lens-combo with a name, icon, color. Per-actor defaults seed it; users edit. The single highest-leverage way to serve culture/ generation differences without forking the UI.
  2. Activity lens — generalize "Recent" into a "what changed, by whom" feed; strong for managers/A&R.
  3. Teams (later) — "team avatars" implies grouping collaborators into named teams (a label's mixing team). Defer as an entity; for now badges use individual collaborator avatars.
  4. External-share hardening — once "external" exists, external links get safer defaults (shorter expiry, optional watermark/download-off) — a trust feature that sells to managers/labels.

Decisions — confirmed 2026-06-07

  1. Roster = a label/color on ArtistSpacesnot a new container. Grouping is a tag + a lens; no hierarchy change. (Layer 1 above.)
  2. Envision-only now — this model is locked on paper; the next build is CRUD slices 2–5 (doc 25). The sharing/identity/lens system is built after the create→review→approve loop is complete. Nothing in this doc ships yet.
  3. True per-version sharing — a single version/file can be shared independently; the backend SharedResource enum + link extend to File granularity. Scheduled with the sharing layer (post-CRUD), not now.

Still open (smaller, decide when sharing is built): the precise internal/external definition if a real team/org entity is ever added; whether tags are workspace-global or per-resource; whether saved views start client-only or get a backend store. None block CRUD.

Build plan — phase kickoff (2026-06-12, owner: "go doc-26")

Backend-contract-first per the routing rule. Grounded findings: 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; the real gaps are the aggregate, the tags store, and the roster field.

Wave 1 — backend contracts (Claude Code) — ✅ implemented 2026-06-12 (tsc clean; boot-verified:

all five routes mapped. Reaches TestFlight on the next container redeploy.)

  1. Roster tag on ArtistSpaceroster?: { name: string, color: string } prop; accepted via the existing PUT /artist/:id; returned by all artist reads. Grouping/filtering is a client-side lens.
  2. Recipient-side share tags (the arrangeable inbound identity) — new shareTag collection: { user, resourceType ('ArtistSpace'|'Project'|'Track'), resourceId, label?, color? }, unique per (user, resourceType, resourceId). Endpoints: PUT /collaboration/my-tag (upsert; body { resourceType, resourceId, label?, color? }), DELETE /collaboration/my-tag/:resourceType/:resourceId. Inbound reads (GET /collaboration/collaborator/{spaces,projects,tracks}) each gain a myTag field.
  3. GET /collaboration/shared-by-me — the long-flagged outbound aggregate: my owned resources that have collaborators beyond me or active links → { spaces, projects, tracks }, each entry with its collaborator list + link summary (count, last access).
  4. Per-version share linksSharedResource gains File; POST /collaboration/share/file (makeFileShareableDto { fileId, role }) creates a link that grants the role on the parent track and preserves the file ref so clients deep-link to that exact version; GET /collaboration/link/file/:fileId lists. (Resolution rides the existing /:linkId/add-user path.)

Wave 2 — iOS slices (Xcode agent, one at a time via next-brief.md)

  1. Shared screen v2 (inbound) — returns from the 2026-06-07 parking, now grounded: grouped inbound items with my-tag chips (label + color, editable in place / via Portal). ✅ shipped 2026-06-12 (Xcode agent, build green) — new Features/Shared/SharedView.swift + Core/Models/SharedItems.swift, three sectioned feeds (Artists / Projects / Tracks) reusing ArtworkHero / ProjectCard / TrackRow; MyTagChip (V#-pill language: 0.18-fill capsule in the tag's color); TagEditorSheet (label TextField + 8-swatch palette aligned to Theme.versionColor(for: 0..<8) + Save / Update / Remove) opened via long-press context menu, routes through PUT/DELETE /collaboration/my-tag and refetches. RootTabsView is Home / Shared / Recents again. Lands on TestFlight after the next container redeploy.
  2. Outbound ("Shared by me") — off the new aggregate; link counts + collaborator avatars. ✅ shipped 2026-06-13 (Xcode agent, build green) — SharedView got a segmented Picker ("With me" / "By me"); outbound segment is one fetch of GET /collaboration/shared-by-me sectioned by Artists / Projects / Tracks, reusing the inbound tiles. New ShareSummaryLine under each item: overlapping initials avatars (up to 3 + +N pill, accent-stable per user)
    • "N link(s) · M opens" caption + a small waveform chip when any link carries a file ref (a version is shared). Both segments fire in parallel on first appearance via async let; pull-to-refresh reloads only the active segment. Tag editing context menu stays on the inbound side only. Lands on TestFlight after the next container redeploy.
  3. Roster UI ✅ shipped 2026-06-13 (Xcode) — name+color on an artist, Artists home groups by roster when any exist; solo-user invariant (no roster → no UI). Detail in CHANGELOG.md.
  4. Portals-everywhere ✅ shipped 2026-06-13 (Xcode) — ArtistPortalContent + ProjectPortalContent (metadata, collaborators, share, my-tag, image/cover upload). Detail in CHANGELOG.md.
  5. Identity badges + share-this-version ✅ shipped 2026-06-13 (Xcode) — Track Portal share + per-version share links; avatar stacks on Shared rows. Detail in CHANGELOG.md.

Wave 2 COMPLETE (2026-06-13), plus a polish/QA pass on top: hero cover display ✅, two-source image picker (Photos + Files) with confirm ✅, Photos-in-Menu regression ✅. Two QA items re-opened (owner): per-track cover in the tracklist (was showing project art on every row) and the intelligent halo on all heroes (was version-only) — both pure-iOS, root-caused, re-queued in .agent/next-brief.md ahead of Wave 3.

Validation gap — Shared (slices 5–6) is unconfirmed. The inbound/outbound Shared activity can't be verified from a single account; it needs a multi-account matrix — existing↔existing, invite-to-a-new account (signup→auto-add), and guest/link access (no account). This is a test-plan gap, not a code bug: seeded test accounts + a written run-through. Flag for when QA tooling lands; doesn't block Wave 3.

Wave 3 — lenses & saved views (client-first) — ✅ SHIPPED 2026-06-14 (Xcode; BuildProject green)

LensBar (Role · Status · Roster facets; solo-user-invisible) + LensManager saved views (persisted to UserDefaults, seeded "My Work" / "Awaiting Approval"), applied to Home / ProjectTracks / Shared. Detail in CHANGELOG.md. The #tags facet shipped as a disabled stub — it needed a backend tags[] store. Tags-scope DECIDED (owner 2026-06-14): tags[] are per-resource, owner-authored, private by default; they propagate to a recipient only via an opt-in "share tags" check, else none — not a global vocabulary. (Distinct from the already-built recipient-side my-tag.) See 33-NOTES-ANNOTATIONS.md §Decisions.

#Tags backend ✅ SHIPPED 2026-06-18 (Claude Code): tags[] + shareTags (owner-private, opt-in) on Artist/Project/Track · GET /search/tags aggregate · StripPrivateTagsInterceptor enforces read privacy. The decision above is honored at both the aggregate (query) and node-read (interceptor) levels. The iOS slice that un-stubs the facet is queued in .agent/next-brief.md. Comment.tags[] (doc-33 notes) deferred.

doc-26 Waves 1–4 COMPLETE (backend + iOS) + the #Tags store. Remaining envisioned: doc-33 notes (Comment.tags[] + Portals notes band) and the Wave-4 extras (break-inheritance, etc.) — all owner-greenlit per phase.

Invariant throughout: lenses filter; roles gate — no lens ever widens access.

Wave 4 — Teams / Groups as share targets — ✅ SHIPPED (backend) 2026-06-17 (Claude Code; nest build clean, boot-verified — all 13 /team routes mapped)

What shipped: a new team module (Team + TeamShare ledger), 13 /team endpoints (CRUD · invite/accept/decline/remove · share/shares/unshare), and reusable id-based grant/revokeCollaboratorCascade on the now-exported CollaborationService. Grant model = fan-out + reconcile-on-join (TeamShare ledger back-grants late joiners, revokes on leave/un-share; no permission/read path changed). **Governance = owner

  • admins; membership by consent (pending→accept).** Invariant preserved: a team lands only as standard per-resource collaborator roles, and only the sharer's owned resources can be shared in. iOS UI is the next slice (.agent/next-brief.md). Scoped out (flagged): break-inheritance (orthogonal, platform-wide), File/version-level team share, "shared via Team" badge UI, and provenance-precise revoke (a grantedVia marker — v1 revoke is by the TeamShare resource list). Detail in CHANGELOG.md.

The envisioning that drove the build (kept for reference):

Today sharing targets individuals (email invite → collaborator) or open links. The owner's scenario: share an artist/project/track to a group at once, and — crucially — "the external user set the current user in a group, and the current user wants to share into that group." So groups must work across ownership boundaries, not just within one account. This is the B2B heart (doc 31): labels and managers think in teams, not individuals.

Proposed model — Team as a first-class, lightweight entity:

  • Team { name, owner, members: [{ userId, teamRole }] } where teamRole ∈ { admin, member }. A team is a reusable set of people, not a permission by itself.
  • Sharing to a team = fan-out, not a new grant primitive. "Share project X to Team Y as editor" adds each current member to X's existing collaborators (the role cascade already built) + records the team as the source (for the sharer-side identity badge / "shared via Team Y"). Members joining the team later get access via a resolve step (or a nightly/触发 reconcile). Invariant holds: the grant still lands as per-resource collaborator roles — teams are a convenience over the gate, never a bypass.
  • Cross-ownership (the owner's case): anyone can create a team and add others; membership is by consent (invite/accept, like collaborator invites today — you're not silently added to strangers' groups). To share into a team you must be a member of it (any member can share their own resources into a team they belong to; they can't grant access to resources they don't own — roles still gate). So: B creates "B's Crew", invites A; A accepts; A can now share A's track into "B's Crew" → every member gets the collaborator role on A's track. Reciprocal by construction.
  • Identity: team avatars become the "team" badge doc-26 Layer 3 reserved; "internal vs external" can later mean shares a team with me vs not.

Decisions to confirm before building (not now — flagged):

  1. Team entity now, or keep individual+link sharing through first release? (Recommend: envision now, build as its own mini-phase after the current Shared waves 6–9 — it's a new entity + invite flow + fan-out/reconcile, comparable in size to Wave 1, and the per-individual sharing already covers the MVP.)
  2. Static fan-out vs live membership — snapshot members at share time (simple, but late joiners miss out) vs resolve team membership on access (always-current, needs a resolve hook on the collaborator/sharedWith read path). Recommend live resolve for correctness, scoped to that wave.
  3. Team ownership — single owner + admins (recommend) vs fully shared. Affects who can rename/disband.

Until built, the current per-individual + link sharing stands; nothing here blocks waves 6–9.

Today every share link (artist / project / track / file) is {webOrigin}/s/{link._id} — a 24-char ObjectId. Two owner concerns, one worth building:

  • ✅ Too long → Link.shortId shipped 2026-06-19 (Claude Code). Added a unique+sparse 6-char shortId (generateShortId()) set on share-link creation; findLinkById resolves by shortId OR _id so new /s/xr7kq2 links work and legacy /s/{_id} links still resolve. Remaining: the client should emit link.shortId in the share URL (one-liner); existing links keep their _id URL (re-share reuses them) — a backfill / lazy-assign on reuse is the optional follow-up. See CHANGELOG.md.
  • ℹ️ What /s/ means — keep it. /s/ is the universal share route ("s" = share); it is not artist-specific. The Link record carries resourceType, so the server resolves artist/project/track/file from the code (the web has /shared/{artist,project,wave} landing pages behind it). An opaque, type- agnostic short path is the correct pattern (short, leaks nothing about the resource) — recommend keeping /s/, not making it type-specific. The only change worth making is the short code above; this entry just documents the convention so it stops being a question.

Cross-references

Ctrl-Audio Platform Documentation