RFC-121: Unified "Saved" model — one favorite concept over favorites + highlights¶
- Status: Draft
- Authors: Marko, Claude (Opus 4.8)
- Stakeholders: Consumer App (learning-player), Server API (per-user state)
- Related RFCs:
docs/rfc/RFC-119-holistic-collections.md(collections pin any typed item; unaffected)docs/rfc/RFC-101-*(Revisit / resurfacing — keys on highlight ids; must survive)- Related UX specs:
docs/uxs/UXS-014-interaction-patterns.md— "Item actions" (OPEN-1/OPEN-2), "Saved & Library", the destructive-confirmation restore test. This RFC resolves OPEN-1 + OPEN-2; the two land together (UXS-014 requires code + spec amended in the same change).- Origin: Operator note-dump 2026-09-09 + two Fable-5 advisor reviews on
feat/player-ux-overhaul.
Pre-launch — no users, no backward compatibility. This app has no users and no data to preserve. There is NO migration, NO legacy-read path, NO dedupe-against-old-data. When a shape changes, the old shape is deleted, not kept. (Operator directive 2026-09-09.)
Abstract¶
- What: Present one user-facing "Saved" concept (the
.lp-favheart) across the whole app, subsuming today's separate "Favorite" and "Highlight" names. Every saved thing — of any kind — may optionally carry the extras highlights carry today (a note, a colour, a marked moment/span, export). Library's Saved surface unifies into one list with kind filters. - Why: The operator's simplification: favorites and highlights both end up in Library, so they should not be two names. One heart, one place.
- The structural correction (non-negotiable): "Saved" is one concept over two identity classes, NOT one record shape. Collapsing them into one shape breaks either multi-moment capture or heart-toggling (see Non-Goals + Risk R1).
The two identity classes¶
| Class | Key | Cardinality | Kinds | Today |
|---|---|---|---|---|
| A — singleton | (kind, ref) |
at most one per ref; idempotent upsert; toggleable | episode, show, topic, person, storyline | favorites.json (app_user_state.py add_favorite/_upsert_in_place) |
| B — capture | id (minted) |
many per episode | insight, moment, span | highlights.json (immutable id, anchor machinery, colour, note, export) |
A moment/span cannot live in (kind, ref): a user marks five moments in one episode, and there is
no ref to toggle. So a "favorite with a moment" is a class-B record. Highlights are not merged
away — they are re-skinned: the word "Highlight" leaves the UI, the record and its endpoints stay.
Data model (UI read model)¶
The UI consumes one Saved record composed at the read layer from both stores; it does not require
a new server record:
Saved = {
kind: EpisodeKind | ShowKind | TopicKind | PersonKind | StorylineKind // class A: key (kind, ref)
| InsightKind | MomentKind | SpanKind, // class B: key = id (ref = id)
ref: string,
// display snapshot (label / sublabel / slug) — today's FavoriteAdd
note?: Note[], // stays a SEPARATE attached object (episode-level notes exist w/o a capture)
color?: string | null, // additive optional; class A gains it, class B already has it
anchor?: { episode_slug, start_ms, end_ms?, char_start?, char_end?, segment_ids?, quote_text?,
speaker?, anchor_status }, // class B only — the existing highlight anchor
source_insight_id?: string | null,
created_at / added_at
}
- Notes stay attached objects, not inline fields —
notes.jsonis deliberately separate so an episode note can exist without a capture, export renders episode notes distinctly, and the delete-sweep works.NoteTargetextends additively:+ 'show' | 'topic' | 'person' | 'storyline'. - Colour on class A — additive optional field on the favorites row (the store already upserts a raw dict; only the route schema needs the field).
- The dead
start_ms?on today'sFavoriteAdd(a legacy insight display field) is removed during the type split so nobody mistakes it for a moment field.
Write paths — and the #1593 ban¶
1593's harm was two write destinations for one insight; its fix: "Highlights is the single¶
destination… do not add a new write path." This unification is compatible only if:
- The insight heart routes to the capture path. The bookmark in
KnowledgePanelbecomes an.lp-favheart that calls the existingcaptureStoretoggle →POST /highlights. Re-skin, not re-plumb — zero data-path change. insightis removed from the favorites kind entirely (422 on write). The serverLiteraldropsinsight(→episode|person|topic, later+show|storyline), so akind=insightPUT fails validation with a 422; the clientFavoriteKinddropsinsighttoo. The favoritesinsightsresponse bucket,AppFavoriteInsight/FavoriteInsight, and the Library Saved›Insights section are deleted — no legacy-read path (pre-launch, nothing to preserve). Insights render in Library via the highlights/capture path only. (Shipped — phase 1.)- The other endpoints stay.
/favorites(class A),/highlights(class B),/notes(extras) keep their existing offline-outbox replay semantics (kind+ref-idempotent vs id-idempotent). A merged endpoint would reconcile the two replay models for zero user value.
Migration — none (pre-launch)¶
- No users, no data to preserve, no migration. The old
insightfavorite bucket is deleted, not kept. There is no dedupe and no legacy-read path. - Unify at the read layer, client-side: a
savedselector composingfavoritesStore+captureStoreinto oneSaved[]. Both already load/cache/offline-flip independently — keep that.
Library IA¶
- Tabs unchanged: Following · Saved · Collections · Revisit (test-defended; #1599 is the cautionary tale — do not touch the tab set).
- Inside Saved: the sections (Episodes · Highlights; the old favorites-Insights section is gone) collapse to one list + kind filter chips (All · Episodes · Insights · Moments · Shows · Topics · People). Chips render only for kinds that have items (preserves the #1962 single-empty-state). The word "Highlights" disappears from the UI; insight/moment/span entries are just saved items.
- Extras on the card: colour as an edge bar, note count/snippet, a
▶ mm:sschip for anchored moments, export in the list toolbar. - Revisit keeps feeding from class-B records, unchanged.
Toggle + delete semantics (resolves the confirm rule)¶
Restore test (UXS-014 destructive-confirmation): heart-off is a free toggle when the record carries
nothing authored; it opens ConfirmDialog when it does.
- Bare class-A favorite → free toggle.
- Class-A favorite with a colour/note → confirm (colour/note are authored; cheapest correct rule =
confirm iff
note ∨ color). - Any class-B record, or any favorite with attached notes → confirm (delete cascades notes + destroys resurfacing state; ids are minted, restore is inexact).
OPEN-1 resolution — Library favorite¶
Keep the heart, inverted to one-tap unfavorite; do not drop it. This satisfies the operator's
"no need to add a favorite on Library" (nothing there offers an add — the ♥ shows saved-state
truth) while matching the invert-don't-drop rule already used for Queue→remove and Downloaded→delete.
The confirm rule above makes one-tap unfavorite safe on noted items. The redundant ⋯ remove in the
Library row is dropped.
Phased delivery (incremental, mostly additive)¶
- Remove
insightfrom the favorites kind (422 on write) + delete the insights bucket. DONE. Server + clientinsightdropped from the favorite kind;AppFavoriteInsight/FavoriteInsight, the responseinsightsbucket, and the Saved›Insights section deleted. Locks in #1593. - Insight bookmark →
.lp-favheart re-skin (routes to the capture path). - Unified
savedread model + Saved filter-chip IA (dedupe legacy insights). - Colour + notes on class-A kinds +
NoteTargetextension. - New class-A kinds
show/storyline(additive Literal both ends; define the storyline ref). - Export extension for noted class-A favorites.
Non-Goals¶
- One physical record shape. Explicitly rejected — see the two classes + R1.
- On-disk migration / endpoint retirement. Out of scope; read-layer unification only.
- Merging Follow into Favorite. Save (heart) and Follow (pill) stay distinct (UXS-014).
Risks¶
- R1 [blocker] — identity-class collapse. Any spec/impl treating "Saved" as one record shape
(e.g. adding span fields to the
(kind, ref)row) breaks multi-moment capture or heart-toggling. The RFC names two classes precisely to prevent this. - R2 [blocker] — banned-path resurrection. Until the server write
Literaldropsinsight, the unified heart is one misdirectedfavorites.togglefrom re-creating #1593. Ship phase 1 first. - R3 [should-fix] — follow vs favorite on entities. favorite(topic) + follow(topic) now coexist on entity cards. Define both per kind in the UXS amendment, or cut class-A entity kinds from phase 1.
- R4 [should-fix] — momentum blind spot. The engagement series counts only
favorites.added_at; insight saves via the highlight path would stay invisible. Fold highlightcreated_atinto the saves tally (verify against the RFC-103 maths). - R5 [nice-to-have] — export coverage. Noted class-A favorites won't appear in export until the exporter learns about them.
Verification / rollback¶
- Each phase ships independently; phase 1 is a pure narrowing (422 on a write kind nothing currently calls) — rollback = revert the Literal.
ci-ui-full(not fast) before push — the highlight→favorite re-skin touches i18n keys, testids, and specs broadly.