V1 Web PWA · Journey

Journey: Library save, collect, share

A source-reconciled journey record for V1 Web PWA — shipped behavior, state boundaries, failure modes, and the automation evidence available today.

automated · 12 specs verdict: partial· 2026-05-29
9sections20 minread

On this page
Journey at a glance
ActorsCurated readerCollection curatorLink-share collaboratorAAA-creator
Automation12 E2E spec(s) · verdict: partial· 2026-05-29

Journey flow#

Generated from the authored steps below — click a node to jump to that section.

flowchart TD s1["1. Save an artifact from a domain surface"] s2["2. Open /library and confirm the save"] s3["3. Narrow the filters"] s4["4. Create a collection from the current filte…"] s5["5. Add multiple items to the collection"] s6["6. Browse priority resume rail"] s7["7. Set sharing permissions"] s8["8. Recipient opens the shared link"] s9["9. Paywall behavior"] s10["10. Library sync across devices"] s11["11. Unsave / remove items"] s1 --> s2 --> s3 --> s4 --> s5 --> s6 --> s7 --> s8 --> s9 --> s10 --> s11 click s1 href "#1-save-an-artifact-from-a-domain-surface" click s2 href "#2-open-library-and-confirm-the-save" click s3 href "#3-narrow-the-filters" click s4 href "#4-create-a-collection-from-the-current-filters" click s5 href "#5-add-multiple-items-to-the-collection" click s6 href "#6-browse-priority-resume-rail" click s7 href "#7-set-sharing-permissions" click s8 href "#8-recipient-opens-the-shared-link" click s9 href "#9-paywall-behavior" click s10 href "#10-library-sync-across-devices" click s11 href "#11-unsave--remove-items" s11 --> v(["verdict: partial"]) click v href "../results/library-save-collection-share.html" style v stroke:#d6a531,color:#d6a531

The cross-view flow from a domain Save action through signed-in BFF sync, /library hydration, browser-local collection editing, and server-enforced share-policy resolution. Saved items and collection sharing policy have real BFF paths; collection labels, descriptions, membership, and archive state remain in localStorage['oshun.library.collections.v1']. The recipient route currently proves the collection ID and granted permission tier—it does not yet render the sender's collection contents. Keeping those three planes separate is the central invariant of this journey.

Personas#

  • Curated reader — saves passages, sources, and assistant excerpts; lives in /library and uses smart filters to find them later
  • Collection curator — builds named collections per project (e.g. "ruling part" notebook companion, "tonight's sky" reading list)
  • Link-share collaborator — wants to share a collection with a colleague via a link-with-key URL without exposing their full library
  • AAA-creator — public-profile surface user; shares a curated collection with provenance and grounding indicators preserved per V1/features.md

Pre-conditions#

  • Signed-in session (per customer/02-home-discovery/library.md)
  • useOshunWebLibraryStore() initialized from showcase defaults and the local cache. For a signed-in member, hydrate() then reconciles real items from /v1/library/saved-items; saves/unsaves are optimistic and fail-soft while their BFF writes run (per customer/02-home-discovery/library.md Data & contracts)
  • localStorage['oshun.library.collections.v1'] writable (or already populated with prior user-created collections). Collection composition is not cross-device today.
  • At least one in-domain Save affordance reachable (per the cross-cutting finding in README.md, Save buttons now wire to useOshunWebLibraryStore in Veritas / Nisaba / Search / Explore / Tara)
  • Service worker active so cached /library route reads work offline (per shell/03-pwa-behavior.md)

Steps#

1. Save an artifact from a domain surface#

Pick whichever surface the user is starting from; all converge in useOshunWebLibraryStore.

A. From Veritas (passage)

  • On /veritas, click “Save to notebook.” The direct producer writes a Veritas saved item through the web store and real BFF, then hydrates it in a clean second browser context.

B. From Nisaba (passage)

  • On /nisaba, the passage actions row exposes NisabaPassageActions “Save passage" — saves a nisaba passage library item per README.md finding 1

C. From Search

  • On /search, each result row exposes a library toggle when canSaveSearchResultToLibrary(result) per customer/02-home-discovery/search.md Library save toggle
  • Click → toggleOshunWebLibraryItem(buildLibraryItemFromSearchResult(...)); trackLibraryItemSaved fires

D. From Explore

E. From Nyx event detail

  • On /domains/nyx/events/<eventId>, click the "Save event" toggle (data-testid="nyx-event-save-toggle") per customer/06-nyx/domains-nyx-events-eventId.md Action row 2
  • Note that Nyx save state is owned by useNyxEventActionState (localStorage oshun:nyx:event-actions:v1), not useOshunWebLibraryStore — verify whether the Nyx save surfaces in /library or only in the Nyx event lane (this is one of the polished-vs-internal dual-fixture patterns flagged in README.md finding 5)

2. Open /library and confirm the save#

  • Navigate to /library via shell nav (or via WEB_SHELL_ROUTE_PATHS.library)
  • ShellLayout active="library" mounts; breadcrumb Home → Library
  • <LibraryDashboard /> mounts; pre-hydration ShellRouteAnchor ("Keep what matters, then return to it deliberately") flashes while hasMounted === false per customer/02-home-discovery/library.md States — Loading
  • Focal stage renders frame title "Saved continuity across the shell" and the four ShellLandingMetricPills (saved count, distinct domains, study count, re-entry count)
  • The just-saved item appears after the signed-in server reconciliation in the filtered saved-items list with LibraryItemSignals badges (ConfidenceBadge, LibraryGroundingBadge, ProvenanceBadge)
  • Verify: customer/02-home-discovery/library.md Saved item row + States — Populated (filters: all/all)

3. Narrow the filters#

  • Click the Domain segment rail (FilterRail<LibraryDomainFilter>, aria-label "Library domain") — pick the domain matching the just-saved item (e.g., Nisaba)
  • Frame title interpolates LIBRARY_DOMAIN_LABELS[domain] (e.g., "Nisaba returns")
  • Click the Kind segment rail (LIBRARY_KIND_FILTERS) — pick the kind (e.g., passage)
  • Frame title now interpolates LIBRARY_KIND_LABELS[kind].toLowerCase()
  • The "Create collection from filters" chip becomes visible (filteredItems.length > 0)
  • Verify: customer/02-home-discovery/library.md Workspace toolbar

4. Create a collection from the current filters#

5. Add multiple items to the collection#

The collection deep-link surface uses the same LibraryDashboard with initialCollectionId pre-set.

  • Verify selectedCollection.label populates the collection editor label input
  • Verify selectedCollection.description populates the description input
  • Edit the label (e.g. "Ruling part — companion") — drives collectionEditor; syncCollection writes back to collections and to localStorage['oshun.library.collections.v1']
  • Edit the description and the followUp copy
  • Switch back to /library (clear the collection — calls navigateToCollection(null)router.push(WEB_SHELL_ROUTE_PATHS.library))
  • Save additional items from any domain (repeat step 1 with different artifacts — passage from Nisaba, claim from Veritas, source from Sophia evidence sidebar)
  • Return to /library/collection/<id> via the collection card click (router.push(buildLibraryCollectionHref))
  • For each saved item, use the selected collection's membership toggle. handleToggleCollectionItem updates collectionItemIds; metrics recompute through withCollectionMetrics and the versioned local payload is rewritten.

6. Browse priority resume rail#

  • Back on /library (no collection), the priority resume rail shows the top 3 items by resumePriority (ContinuityCard)
  • Each card opens buildLibraryResumeHref(item.domain, item.resumePath) (domain root + ?path=…)
  • Click one to verify the resume path actually lands in the right domain at the right depth

7. Set sharing permissions#

Sharing controls per V1/features.md: private, shared with named users, shared via link with permissions (view, comment, copy), and public-profile surface where the customer has opted in.

  • Open the collection editor for the just-created collection
  • Locate the "Share" / "Visibility" controls — CollectionShareControls mounts on /library/collection/research-to-practice and library-save-collection-share.spec.ts asserts the share-control mount.
  • Pick visibility: Private (only the user; default)
  • Save — the collection retains private visibility via the real PUT /v1/library/collections/:id/share BFF route.
  • Switch to Link with key — a tokenized share URL is generated by the real POST /v1/library/collections/:id/share-links route. The current browser route is /share/:token?target=collection&collectionId=... rather than the older local /library/collection/<id>?key=... draft shape.
  • Copy/link result exposes the granted permission tier (view / comment / copy) per V1/features.md
  • Switch to Public — the public-profile slug/tier controls persist to the real BFF policy and are read back by collection-share.spec.ts.
  • Embeddable share cards: per V1/features.md "Embeddable share cards for stories, claims, passages, sky events, meditations, and lesson modules with provenance, grounding, and synthetic indicators preserved" — verify the embed-card generator if surfaced
  • In a second browser session (different account, or anonymous), open the shared URL
  • Private / revoked / wrong token: recipient-side BFF resolution returns 403 and does not grant the collection. library-shared-resolution covers wrong-token and revoked-token denial; the route/store tests cover private denial.
  • Link with key: the recipient page renders access granted/denied with the permission scope (view / comment / copy); the BFF denies a request above the token's tier. library-shared-web-view also proves the page does not mount the recipient's local LibraryDashboard collection even when localStorage has a same-id shadow collection.
  • Public-profile policy write: public-profile slug/tier is persisted and read back through the real BFF policy. The full sender public-profile page rendering for collections remains covered separately by public-profile surfaces, not this collection-share journey.
  • Embeddable/public share cards: synthetic-content indicators and grounding-state preserved per V1/features.md
  • Verify: V1/features.md sharing-permission isolation tests

9. Paywall behavior#

Per the cross-cutting README — Aje owns Web3 / crypto payments per V1/ARCHITECTURE.md. For any artifact behind a tier paywall:

  • If a saved item is gated by entitlement (e.g., AAA-creator-only reading), the library card surfaces the gate inline; clicking "Resume" routes through the entitlement gate
  • Per customer/13-system/aaa-upgrade.md — middleware bounces unentitled users to /aaa-upgrade rather than showing the artifact
  • Crypto-payment rail handled by libs/oshun/aje/* per V1/ARCHITECTURE.md; flow is out of scope for /library itself — the library only displays the gated badge

10. Library sync across devices#

Saved items now implement the cross-device half of the feature contract; collection composition does not.

  • On a second device (signed in as the same user), open /library without copying Library localStorage.
  • webLibraryStore.hydrate() reads /v1/library/saved-items; the item appears and resumes its canonical destination.
  • Guest saves are journaled under oshun.web.library.pending-auth-migration.v1 and reconciled through /v1/library/saved-items/sync after authentication.
  • User-created collection labels, descriptions, membership, and archive state follow the user across devices. They remain localStorage-only.
  • Verify broader workspace handoff in multi-device-workspace-handoff.md HomeMultiDeviceContinuityPanel — Iris-driven preference sync

11. Unsave / remove items#

  • On any saved-item card, click the Save toggle to unsave
  • handleLibraryItemToggle(item) flips the store entry; the row disappears from the filtered list
  • trackLibraryItemUnsaved analytics fires per customer/02-home-discovery/library.md Save toggle
  • Inside a collection: per-view file flags "remove item from collection" — setCollectionItemIds mutates the collection's item set
  • Delete a user-created collection — per-view file Open question: "Confirm whether user-created collections can be deleted (the source defines LIBRARY_COLLECTIONS as readonly; only stored extras appear removable)"

Post-conditions#

  • A signed-in saved item has a server record and can hydrate in a clean browser; the local cache remains an optimistic/offline aid.
  • A created or edited collection has a version-1 localStorage payload matching the in-memory label, description, follow-up, and member IDs.
  • A generated share link resolves through the BFF at no more than its granted tier; wrong, revoked, private, and insufficient-permission requests fail closed.
  • The recipient never substitutes a same-ID collection from its own local Library state.
  • Save/unsave UI emits the verified item_saved / item_unsaved analytics events. Collection composition and share-policy writes do not imply those item events.
  • The shared page proves access policy only; collection content delivery and embeddable provenance cards remain open.

Failure modes#

  • In-domain save writes to one store; /library reads from another — Nyx event save uses useNyxEventActionState (separate localStorage key oshun:nyx:event-actions:v1); confirm whether Nyx saves surface in /library or only in the Nyx event lane (per README.md finding 5)
  • Anonymous → signed-in transition loses saves covered — guest saves are journaled under oshun.web.library.pending-auth-migration.v1, then replayed through the real /v1/library/saved-items/sync route when an access token appears. library-save-collection-share.spec.ts signs in through /welcome, reads the migrated row back from the BFF, and opens /library in a clean second browser context to prove the row is server-backed rather than localStorage residue.
  • Stored collections corrupted / missing fallback coveredreadStoredLibraryCollections returns null; default collections render and user-created corrupt payload rows are ignored per customer/02-home-discovery/library.md States and the collection-route E2E addendum
  • Collection deep link 404 fallback coveredpage.tsx does not 404; the dashboard preserves the unknown URL, clears collection management, and applies valid query-seeded filters per customer/02-home-discovery/library-collection-collectionId.md States
  • LocalStorage version migration coveredlibrary-collection-management.spec.ts now seeds a legacy version: 0 oshun.library.collections.v1 payload, opens a direct /library/collection/<id> link against the real authenticated shell + BFF-seeded saved items, verifies duplicate and stale item IDs are sanitized, and polls localStorage until the payload is re-persisted as current version: 1.
  • Sharing link shows recipient's own collection — fixed by the BFF-backed /share/:token route. library-shared-web-view opens a generated token URL in a different signed-in browser session with a same-id local shadow collection and proves the shared page resolves the BFF grant instead of rendering the recipient's local collection editor.
  • URL ?domain= / ?kind= not stable — per-view file Open question: "Confirm whether ?domain= and ?kind= are stable in the URL after the user changes filters from inside the dashboard (filters mutate state but this page.tsx does not write them back to the URL)"
  • Resume href navigates to a domain root that requires network — saved items are local store, but link resumes navigate to domain surfaces that may require network per customer/02-home-discovery/library.md Open questions; offline branch is not wired in the library view
  • Paywall bypass — gated artifacts must route through the entitlement gate; saving the item to the library should not grant access. Per customer/13-system/aaa-upgrade.md middleware enforces upstream
  • Share with provenance stripped — embeddable share cards must preserve grounding, synthetic, and provenance indicators per V1/features.md; verify the share-card renderer doesn't drop these on small breakpoints

E2E coverage#

  • Backed by apps/oshun/web/e2e/library-collection-management.spec.ts and apps/oshun/web/e2e/library-save-analytics.spec.ts — the collection spec drives the collection deep-link route, creates a collection from a domain filter, edits its label, adds and removes an item via toggle, archives it, and covers unknown collection fallback, query-seeded filters, corrupt stored collections, stored custom collection hydration, and legacy collection localStorage migration (version: 0 → current version: 1 with duplicate/stale item IDs sanitized); the analytics spec asserts item_saved/item_unsaved telemetry events from Explore, Search, Library, and the assistant panel.
  • Backed by apps/oshun/web/e2e/library-entitlement-and-save.spec.ts — real HTTP integration against the live BFF (step 9 + the server side of steps 1–2). (Paywall) the generation-surface entitlement gate GET /v1/entitlements/generation/surface/:surface: the default (contemplative) tier is denied a gated AAA surface (graph-editordeny / 404-hard-block) but allowed a curated one, a higher x-oshun-creator-tier (aaa-creator) unlocks it while curated-creator stays denied per-surface, unknown surface → 400 unknown_surface, no bearer → 401. (Save round-trip) the server-authoritative POST/DELETE /v1/favorites/:id toggle (favorited:truefavorited:false) and the GET /v1/library aggregation envelope asserting the tara-branch mapping (itemType favorite, key tara:<id>), each branching on the fail-closed Tara upstream (201/200 when up, 502 upstream_error when down) plus 403 domain_scope_missing / 401 gates.
  • Backed by apps/oshun/web/e2e/collection-share.spec.ts (new: A-to-Z sharing)steps 7–8 wired end-to-end against the real BFF: the per-collection share controls (CollectionShareControls on the collection-detail view) create a real tokenized link-with-key via POST /v1/library/collections/:id/share-links (the one-time token is shown), revoke it via DELETE …/share-links/:tokenHash, set the collection private via PUT …/share, assign an explicit named-user grant via the visible named-users controls, and publish a public-profile slug/tier via the visible public-profile controls. The browser spec reads the live BFF policy back after the named-user and public-profile saves, so those modes are not selector-only coverage. The sharing engine itself (four-tier visibility, link expiry/revoke, constant-time recipient resolution, tenant isolation) is proven at the BFF layer by apps/oshun/bff/src/__tests__/library-sharing-route.test.ts.
  • Backed by apps/oshun/web/e2e/library-save-collection-share.spec.ts — real-BFF saved-items seed/readback for the rows this spec asserts, plus a direct /domains/nisaba?path=/library/passage/... non-search save producer: the browser clicks the Nisaba reader save toggle, waits for the live /v1/library/saved-items/nisaba/<passageId> write, verifies persisted metadata, and hydrates the passage row from a clean second-device /library load. The same spec also covers the library resume rail, saved queue filters/snooze/complete/resume, collection share controls mounting, saved-item unsave row removal, and the anonymous-to-signed-in save migration failure mode: a pending guest item is carried through the real /welcome sign-in flow, reconciled by the real BFF /v1/library/saved-items/sync endpoint, then rendered from a clean second-device /library hydrate.
  • Backed by apps/oshun/web/e2e/veritas-evidence-trail.spec.ts — the non-search Veritas lead save producer now resets the current lead's saved row, clicks /veritas "Save to notebook", waits for the real /v1/library/saved-items/veritas/<itemId> write, verifies persisted passage metadata, and hydrates the saved row from a clean second-device /library load with no copied Library localStorage.
  • Backed by apps/oshun/web/e2e/search-saved-and-recent.spec.ts — the real-BFF search-result save-producer branch for steps 1B–2: save a live Nisaba result through /v1/library/saved-items, prove the in-domain Nisaba reading action is already saved, drive unsave/resave through real DELETE/POST responses, hydrate /library in a fresh second browser context with no library localStorage, and resume the exact passage workspace. The same spec now also saves Veritas claim/story, Tara practice-as-meditation, Nyx sky-event, and Metis course search results, verifies their saved-items metadata, hydrates each through a cache-free Library filter, and resumes each canonical destination.
  • Backed by apps/oshun/web/e2e/explore-real-search-continuity.spec.ts — the live Explore-dashboard result save producer now starts from a real /v1/search Nisaba result, clicks the /explore row save toggle, waits for the real /v1/library/saved-items/nisaba/nisaba-passage-speech write, verifies the Explore-specific saved metadata, and hydrates the saved passage row from a clean second-device /library load.
  • Backed by apps/oshun/web/e2e/sophia-grounded-answer-page.spec.ts — the direct /sophia grounded-answer save producer now submits to the live Sophia BFF, saves the answer card through the real /v1/library/saved-items/nisaba/<itemId> route, verifies persisted query/primary-witness/surface/trust metadata, and hydrates the assistant-excerpt row from a clean second-device /library load.
  • Backed by apps/oshun/web/e2e/atelier-plate-actions.spec.ts — the non-search Atelier generated-artifact save producer now saves a kept plate with the browser-edited prompt, reads the persisted metadata back from the real /v1/library/saved-items BFF, and hydrates the generated-artifact row in a clean second browser context with no copied Library localStorage.
  • Backed by apps/oshun/web/e2e/library-shared-resolution.spec.ts (new: recipient-side resolution) — closes the loop collection-share.spec.ts opens by exercising the real-HTTP recipient share-link resolution against the live BFF: a valid token grants the tier (granted / via=link), a wrong token 403, and a revoked link 403. This is the headline opt-out/scope invariant from steps 7–8 (the sender creates links; the recipient's read must enforce the grant) that the share-controls spec created links for but never resolved.
  • Backed by apps/oshun/web/e2e/library-shared-web-view.spec.ts (new: recipient-side web view) — drives the generated /share/:token?target=collection&collectionId=... URL in a second signed-in browser session. The page resolves the share through the real BFF, renders the granted collection id/tier/via state, proves the route does not mount the recipient's local LibraryDashboard collection editor even when localStorage contains a same-id shadow collection, and renders a real BFF insufficient-permission denial when the URL asks for copy permission from a view token.
  • Coverage depth: partial — see coverage.md.
  • Uncovered:
    • Step 10 now has browser-level save→sync→resume coverage for the seeded search-result paths plus the non-search Atelier generated-artifact producer and direct Nisaba/Veritas passage producers: device A saves through the real BFF, device B hydrates /library without local cache, and the Library row resumes the asserted destination. The live Explore-dashboard search lane now has the same BFF-write/clean-device-hydrate proof for a Nisaba passage; the direct Sophia grounded-answer card has the same proof for a Nisaba assistant-excerpt. Remaining gaps are other non-search save producers beyond Atelier/Nisaba/Veritas/Sophia and broader per-domain save-toggle permutations outside the seeded Search/Explore/Sophia matrix.

Per-view files touched#

Cross-references#

  • Feature spec:
    • V1/features.md § Customer Curation, Notebooks, Collections, and Sharing
  • Architecture:
    • V1/ARCHITECTURE.md — Aje (Yoruba commerce orisha) owns crypto/Web3 payment substrate; any paywall behavior in the library routes through Aje's adapter
    • V1/ARCHITECTURE.md — shell library route ownership
  • BFF routes:
    • apps/oshun/bff/src/routes/library-saved-items.ts — signed-in saved-item snapshot, save, delete, and guest-migration sync
    • apps/oshun/bff/src/routes/library-sharing.ts — collection share policy, token lifecycle, and recipient resolution
    • apps/oshun/bff/src/routes/library.ts — legacy aggregate/favorites read
  • Related journeys:
  • Component sources:
    • apps/oshun/web/src/components/library/LibraryDashboard.tsx
    • apps/oshun/web/src/components/library/libraryModels.ts
    • apps/oshun/web/src/components/library/librarySavedItems.ts
    • apps/oshun/web/src/components/library/ResearchPracticeJourneyRail.tsx
    • apps/oshun/web/src/lib/library/webLibraryStore.tsuseOshunWebLibraryStore, toggleOshunWebLibraryItem
    • apps/oshun/web/src/lib/library/librarySaveMapper.tsbuildLibraryItemFromSearchResult

Open questions / known gaps#

  • The signed-in saved-item endpoint is /v1/library/saved-items; webLibraryStore pulls it during hydration and pushes non-demo saves/unsaves in the background.
  • Document the share-permissions contract end to end — the shipped local-dev contract is CollectionShareControls plus /v1/library/collections/:id/share, /v1/library/collections/:id/share-links, /v1/library/shared/:collectionId, and /share/:token. Browser coverage proves private, link/invite/referral, named-users, public-profile policy writes, recipient grant rendering, insufficient-permission denial, wrong token denial, and revoked-token denial.
  • Snapshot the embeddable share-card renderer per artifact type (passage, claim, sky event, meditation, lesson module) and verify provenance / grounding / synthetic indicators are preserved
  • Confirm whether Nyx event saves surface in /library or only in /domains/nyx/events?filter=saved (separate store, per README.md finding 5)
  • Per-view file Open question: confirm whether user-created collections can be deleted (the source defines LIBRARY_COLLECTIONS as readonly; only stored extras appear removable)
  • Per-view file Open question: document collection sharing semantics — the pre-BFF localStorage risk is closed. Collection editor deep links remain personal, while actual recipient shares use the BFF-generated /share/:token?target=collection&collectionId=... URL and the recipient-side page intentionally avoids the personal Library editor.
  • Persist collection composition itself—or clearly keep it device-local—and make the recipient route render the authorized collection content rather than only its ID and permission grant.