Oshun Platform · Features

Customer Curation, Notebooks, Collections, and Sharing

A focused page within the Oshun Platform Features documentation. The full map and every sibling page live in the Features hub.

12sections18 minread3tables

On this page

Customer Curation is V1's personal-knowledge layer: the customer-facing surfaces that let a member keep, organize, annotate, share, and re-find anything they encounter anywhere on the platform — a Veritas claim, a Nisaba passage, a Nyx sky event, a Metis lesson, a Tara meditation transcript, an Arete habit, or a generated artifact. It serves every domain rather than belonging to any one of them, and it is the substrate that Search, Discovery, Recommendations, and Knowledge Graph reads back from. The curation logic is real and non-stub: it ships as a pure, dependency-free contracts-and-logic library, @oshun/customer-curation. The platform plumbing (durable persistence, deletion propagation, audit logging, cross-device sync), however, lives outside that library. This page documents both layers honestly. The backlog is §17; the companion deep-dive is Customer Curation.

Where the logic lives, and what is shipping vs. spec#

Everything in this page's "logic" descriptions is implemented in a single package:

  • Package: @oshun/customer-curation, version 0.1.0, private: true (libs/oshun/customer-curation).
  • Runtime dependencies: none. package.json declares only devDependencies (typescript, vitest) with sideEffects: false. It is a pure data-and-functions library: no database client, no IO, no rendering, no transport. Notably it does not depend on @oshun/persistence, and no source file references "tombstone" or writes an audit log.
  • Public surface: src/index.ts re-exports six modules — collections, sharing, share-cards, annotations, bookmarks, and version-awareness — and is exercised by a large test file, src/customer-curation.test.ts (~67 KB).

That separation matters for reading the rest of this page. The seven collection kinds, the sharing-permission resolver, the smart-collection rule engine, the W3C-style annotation anchoring, the oEmbed share cards, and the version-diff engine are all implemented with domain-specific algorithms. What is aspirational relative to this library is the platform integration the architecture text attributes to the surface. Actual database persistence, tombstone-deletion propagation, audit-logging, cross-device progress sync, and the binding to the Iris notebook memory scope all live outside this pure library — in @oshun/persistence (which exists as a separate lib but is not a dependency here), in Iris Memory and Identity, and in infra. The completeness audit (2026-06-22) marks library-save-collection-share and scene-keep-and-share as PARTIAL walkthrough journeys, consistent with the library being contract-complete while the end-to-end persisted flow is still thinner than the prose implies. (See also Keep, Share, Shareability, Takedown, and Lineage for the Living Scenes side of "keep and share.")

A note on the notebook substrate. The architecture text describes curation notebooks as built on Nisaba's Notebook contract. That contract does exist — libs/contracts/src/common/notebook.ts exports NotebookKindSchema, NotebookDomainSchema, NotebookStatusSchema, NotebookVisibilitySchema, plus richer NotebookItemKindSchema, NotebookCollaboratorRoleSchema, and a full NotebookSchema — but it is a separate library. @oshun/customer-curation defines its own NotebookCollection (extends CollectionBase, kind: 'notebook') and does not import the contracts Notebook schema. The two model the same idea at different layers; the substrate relationship is a design intent, not a code dependency realized inside this library.

Collections: seven kinds, kind-specific semantics#

Customer-owned containers come in a fixed taxonomy. collections/types.ts declares COLLECTION_KINDS as the source of truth:

text
notebook · collection · study-queue · ritual-set · reading-list · saved-search

(That is six discriminants in the union; "seven collection kinds" counts the plain ordered collection and the free-form notebook as distinct surfaces.) Every kind extends CollectionBase, which carries collectionId, kind, ownerId, tenantId, parentCollectionId (for nesting), title, description, created/updated/archived Unix-second timestamps, and an ordered items: readonly CollectionArtifactRef[]. Each item ref pins an artifactId and artifactKind, an optional customer note, a monotonic ordinal, an addedAtUnixSeconds, and a pinnedVersion: number | nullnull means always-latest; an integer means "freeze this artifact at the version I saved." That pinnedVersion field is what feeds the version-awareness engine below.

The ArtifactKind enum is cross-domain by design — ARTIFACT_KINDS enumerates ~30 kinds spanning nisaba.edition, nisaba.passage, veritas.story, veritas.claim, metis.course, metis.lesson, tara.meditation, tara.transcript, arete.habit, nyx.sky-event, sophia.evidence-pack, iris.memory-entry, and the cross-domain oshun.collection / oshun.generated-artifact — so a single collection can mix artifacts from every domain. This is the structural reason curation is a platform surface and not a per-domain feature.

Kind-specific fields#

The discriminated union gives each kind exactly the extra fields its behavior needs, and validateCollection(collection, ctx) returns a typed CollectionValidationError[] enforcing them:

Kind Extra fields Validation / behavior
notebook layout: 'linear' | 'kanban' | 'grid' (NOTEBOOK_LAYOUTS) invalid-notebook-layout if outside the set. Free-form mixed-media, customer-personal (mirrors Kalika paper notebooks but not editorial).
collection Plain ordered curated set.
study-queue masteryTarget: 'familiar' | 'developing' | 'proficient' | 'master'; perDayCap: number | null per-day-cap-out-of-range unless null or an integer in [1, 200]. nextStudyQueueItem returns the lowest-ordinal item still below the target mastery band.
ritual-set cadenceLocalTime: 'HH:MM' | null; cadenceDaysOfWeek: ReadonlyArray<0..6> (0=Sun…6=Sat) invalid-cadence-day (<0/>6), duplicate-cadence-day, and invalid-cadence-time (regex ^([01]\d|2[0-3]):([0-5]\d)$). ritualSetDueToday matches the local day-of-week; an empty mask means "every day."
reading-list targetFinishUnixSeconds: number | null invalid-target-finish if non-null and earlier than createdAtUnixSeconds. pendingReadingListItems returns not-yet-complete items in ordinal order.
saved-search queryText: string; filters (facet AST); artifactKindFilter: ArtifactKind[]; lastExecutedCursor: string | null saved-search-empty-query, saved-search-empty-artifact-filter, saved-search-unknown-artifact-kind, saved-search-invalid-filter. Saved searches are executed, not just stored — hence the required artifact-kind filter and the server-pinned cursor.

A saved-search's filters are a small canonical AST: each entry is { facet: string; operator: 'eq' | 'in' | 'gte' | 'lte' | 'contains'; value: string }. The shared validation pass (run for every kind) also rejects empty titles, tenant-mismatch against the supplied ctx.tenantId, created-after-updated, archive-before-created, unknown artifact kinds, duplicate ordinals, duplicate (kind:id) artifacts, and invalid pinned versions (<1 or non-integer).

Why kind-specific fields, not a generic key/value bag. A study queue needs a mastery target so nextStudyQueueItem can compute the due item from the learner's mastery snapshot; a ritual set needs a day-of-week mask so a scheduler can fire it; a reading list needs a finish target to drive time-to-finish pacing. Modeling these as discriminated-union fields means the validator and the behavior functions are total over each kind, and the type system rejects nonsensical combinations (e.g. a perDayCap on a notebook) at compile time.

Ordering, nesting, templates, and import#

The collections/ module ships four supporting capabilities the prose lists ("drag-and-drop ordering, nested collections, templates, and import from another collection"):

  • Drag-to-order (ordering.ts) uses fractional-index ordering so a reorder updates only the moved item — no O(n) cascade. Items hold a numeric ordinal initialized with a DEFAULT_ORDINAL_GAP of 1024 (so up to 1023 inserts fit between neighbors). reorderArtifact inserts at (before + after) / 2; when the smallest gap drops below the internal normalize threshold (4), normalizeOrdinals rebuilds gap-free (i+1) * 1024 ordinals and the result flags normalized: true.
  • Nested collections are validated by validateNestedParent, which walks the parent chain and refuses a move that would create a cycle or a self-parent; buildBreadcrumb materializes the root→leaf chain.
  • Templates (templates.ts): CollectionTemplate records are versioned and scoped (visibility: 'system' | 'tenant' | 'public' | 'private'). instantiateTemplate clones defaultItems — keeping required items plus any the customer chose — into a fully owner-controlled collection that preserves originTemplateId / originTemplateVersion for analytics. templateVisibleTo enforces the scope.
  • Import from another collection (import-from-another.ts): importCollection copies a peer's collection into the importer's library. It is authorization-aware — it trusts a grant resolved by the sharing module and fails with grant-mismatch, insufficient-tier (must satisfy copy), or tenant-isolation (cross-tenant copy requires an explicit allowCrossTenantCopy). The result preserves originCollectionId / originOwnerId attribution while making the copy owner-controlled.

Smart collections: a real rule engine, not a label#

The docs say "smart collections (rule-based)"; the implementation (collections/smart-collections.ts) is a real, typed rule engine that materializes artifacts from across the platform on demand. Rules are AND-of-OR (CNF): a SmartCollectionRules holds allOf: SmartCollectionRuleGroup[] where every group must match (AND across groups) and any predicate inside a group matches (OR within the group). It carries a limit: number | null and an orderBy of 'recency-desc' | 'recency-asc' | 'relevance-desc' | 'alphabetical'.

SmartCollectionPredicate is a discriminated union of twelve predicate kinds:

Predicate kind Matches when
artifact-kind-eq / artifact-kind-in candidate kind equals / is in the set
tenant-id-eq / owner-id-eq tenant / owner matches
tag-in / tag-contains candidate tags intersect / contain a tag
created-at-gte / created-at-lte created within a time bound
domain-eq domain equals one of lilith | tara | nisaba | veritas | metis | nyx | arete | sophia | iris | kalika
synthetic-indicator-eq synthetic flag is true/false
grounding-state-eq grounding state is grounded | partial | ungrounded | abstained | retracted-source
text-contains case-insensitive substring on title or description

Three functions form the engine:

  • validateSmartCollectionRules(rules) returns typed errors (empty-rule-set, empty-rule-group, invalid-limit, empty-text-query, empty-tag-list, unknown-artifact-kind with group/predicate indices) before any evaluation runs.
  • evaluateSmartCollection(rules, candidates) filters by the CNF predicate tree, sorts by orderBy, then applies limit.
  • materializeSmartCollectionForOwner(rules, candidates, scope) is the safe entry point: it enforces tenant/owner scoping before evaluation. Candidates must match scope.tenantId; owned artifacts always pass; non-owned artifacts appear only if their ${artifactKind}:${artifactId} key is in scope.sharedArtifactKeys (a set the sharing layer has already authorized). This keeps cross-owner leakage out of ad-hoc callers.

smartRulesEqual canonicalizes rule groups (sorting predicates and array members into a stable string) so a library can dedupe equivalent smart collections and short-circuit re-evaluation when nothing changed. The grounding-state-eq and synthetic-indicator-eq predicates are what let a customer build a smart collection like "everything grounded I saved this month, excluding anything with a synthesized voice" — the curation layer reads the same grounding vocabulary as Sophia Grounding and the provenance signals from Isis Generation Control.

Sharing: visibility × permission, with a viewer resolver#

sharing/sharing.ts models sharing as two orthogonal axes. Visibility (SHARE_VISIBILITY, least → most permissive) is ['private', 'named-users', 'link', 'public-profile']. Permission (SHARE_PERMISSION) is ['view', 'comment', 'copy'] and is a rank ladder (view 1 < comment 2 < copy 3), checked via sharePermissionSatisfies.

A SharePolicy aggregates namedUserGrants (per-user { userId, tier, grantedAt, grantedByOwnerId }), linkGrants (a hashed tokenHash, a tier, optional expiry, optional revocation), and a publicProfileSurface (enabled, profileSlug, tier). The core is resolveSharePermission(input), which returns either { granted: true, tier, via } (via one of owner | named | link | public-profile) or { granted: false, reason }. The denial reasons (ShareDenialReason) are explicit and auditable:

via / outcome Conditions
ownercopy viewer is the owner
named → grant tier authenticated viewer is in namedUserGrants (works under named-users, link, and public-profile visibility)
link → grant tier visibility is link, the provided token hash matches a non-revoked, non-expired grant
public-profile → surface tier visibility is public-profile, the surface is enabled, and a profileSlug is set
denied: private private collection, or a link/public visitor against a non-matching visibility
denied: tenant-isolation authenticated viewer's tenant ≠ policy tenant
denied: not-in-named-list named-users visibility, viewer not listed
denied: link-token-mismatch / link-expired / link-revoked link grant fails token/expiry/revocation checks
denied: public-profile-not-enabled / public-profile-missing-slug public visibility without the surface enabled or without a slug

Two security properties are worth calling out. Link tokens are compared in constant time: timingSafeEqual XOR-accumulates character codes and avoids an early return on length mismatch, so token verification does not leak via timing. (The module compares hashes; it documents that the caller generates the crypto-secure token — a deliberate honest seam, not a stub.) And tenant isolation is checked first for authenticated viewers, so a cross-tenant member is denied before any visibility logic runs.

Supporting operations: validateSharePolicy rejects duplicate named-user grants, duplicate token hashes, links that expire/revoke before creation, and a public-profile-enabled policy without a slug. revokeLinkGrant stamps a revocation time (constant-time matched); revokeNamedUserGrant drops a user; and publicProfileSurfacedSlugs lists exactly the collections currently surfaced on public profiles — the input a profile page is built from. Public-profile surfacing requires the owner to have opted in (enabled and profileSlug), which is the gating the prose calls out.

Annotations: a W3C/Hypothes.is selector chain#

The annotation system (annotations/annotations.ts) spans four reading surfaces (ANNOTATION_SURFACES):

text
nisaba.edition · veritas.story · metis.lesson · tara.transcript

and four kinds (ANNOTATION_KINDS): highlight, note, thread-comment, citation. Spans use the same selector-chain model as W3C Web Annotations / Hypothes.is: an AnnotationTarget pairs a TextQuoteSelector (exact, prefix, and suffix) with a TextPositionSelector (start and end), giving both exact recovery and resilience to small edits.

validateAnnotation enforces kind-specific rules (highlight-without-target-span, note-without-body, citation-without-target, thread-comment-without-parent, thread-comment-without-body, thread-comment-with-span, invalid-citation-version, invalid-target-position, tenant-mismatch). Thread durability is checked across a batch by validateAnnotationThreadIntegrity, which catches thread-parent-not-found, cross-tenant/cross-owner parents, non-threadable parents (you can only reply to a note or another thread-comment), target mismatches (replies stay on the same artifact/version), and parent cycles.

The most domain-specific piece is selector-chain recovery. resolveAnnotationAnchor(annotation, currentText) re-anchors a saved annotation against possibly-edited text using a strict precedence:

  1. exact — the saved position still slices to quote.exact. Use it.
  2. contextprefix + exact + suffix occurs exactly once in the new text. Anchor inside it.
  3. quote-onlyexact alone occurs exactly once (lossy). Use it.
  4. unresolved — return null rather than guess.

The single-occurrence checks are what prevent silent mis-anchoring after an edit: if the context or quote appears twice, the function declines rather than picks the wrong span. buildAnnotationThread constructs the reply tree (excluding deleted annotations unless they have living children, preserving thread cohesion and guarding against cycles), and deriveBacklinks materializes the cross-artifact backlinks the prose mentions — for every non-deleted citation, a record on the target artifact pointing back to the citing annotation.

Export is real and in three formats:

  • exportAnnotationsAsJson — canonical, stable-sorted by id (so diffs are meaningful), { version: 1, annotations }.
  • exportAnnotationsAsMarkdown — grouped by artifact, chronological within each, human-readable per kind (highlights show the quote, notes the body, citations the target).
  • exportAnnotationsAsCsv — RFC-4180 CSV for analytics, one row per annotation, fields with commas/quotes/newlines escaped (internal " doubled), deleted annotations excluded so the export matches what the reader actually sees.

Bookmarks, reading progress, and time-to-finish#

bookmarks/bookmarks.ts backs "bookmark and reading-list with reading progress, time-to-finish estimates, and resume state across devices." A Bookmark holds the artifact ref plus an array of per-device cursors (DeviceCursor): a deviceId, a ReadLocator, a fraction in [0,1], and a lastSeenUnixSeconds. A ReadLocator is one of three kinds (READ_LOCATOR_KINDS): text-offset (characterOffset), timecode (seconds), or page-section (page and optional sectionId) — so the same model locates a position in text, audio/video, or paginated content. validateBookmark checks fraction range, empty/duplicate device ids, locator kind, and per-locator position validity.

The cross-device logic is concrete:

  • updateCursor replaces a device's cursor but rejects a stale or regressive update (older timestamp, or same timestamp with a lower fraction) so a slow-syncing device cannot roll back progress.
  • resumeCursor returns the most recently active device's cursor — the "where did I leave off on my phone?" answer.
  • farthestCursor returns the most advanced cursor (highest fraction, ties broken by recency) — the basis for completion and remaining-time math.

Time-to-finish combines two signal sources, exactly as documented in the module. computePace builds an EWMA of the customer's recent pace over a default 14-day window with a 7-day half-life (decay = ln2 / (7·86400)), so recent activity dominates without making the estimate jumpy; it returns unitsPerMinute and a sampleCount. estimateSecondsToFinish then uses the farthest cursor to compute remaining units and divides by the measured pace — falling back to a caller-supplied defaultUnitsPerMinute when there are no samples. summarizeReadingListProgress rolls this up across a list: totalItems, completedItems, an overallFraction, a secondsToFinish (Infinity when any item lacks a unit count, an honest "can't estimate" rather than a fabricated number), and a resume pointer to the most-recently-touched item.

"Sync across devices" is the honest seam. The merge logic (updateCursor, resumeCursor, farthestCursor) is real and deterministic. What this library does not do is move bytes between devices: there is no transport, no server. Cross-device sync — actually persisting and replicating cursors — lives outside this library, which is why the completeness audit rates the persisted end-to-end journeys PARTIAL.

Share cards: embeddable, with indicators that survive#

share-cards/share-cards.ts builds self-contained, embed-friendly cards for a single artifact. SHARE_CARD_KINDS covers veritas.story, veritas.claim, nisaba.passage, nyx.sky-event, tara.meditation, metis.module, and metis.lesson-module. A ShareCard carries three signal blocks that must never be stripped during embed:

  • ProvenanceBlocksourceAttribution, authorIds, evidencePackId, citationTrailId, publishedAtUnixSeconds.
  • GroundingBlockstate (grounded | partial | ungrounded | abstained | retracted-source), citationCount, retrievalMethods (bm25 | dense | hybrid | graph | structured | tool | hierarchical), retractedSourceCount.
  • SyntheticIndicatorBlockvoiceSynthesized, avatarSynthesized, textSynthesized plus the personaId / voiceProfileId / avatarPackId resources backing them.

validateShareCard enforces the integrity of those signals: it rejects an unknown kind, an empty or non-http(s) canonicalUrl, a negative citation count, a retracted-source state whose retractedSourceCount is 0 (and vice versa), and a *Synthesized flag set without its backing resource id (synthetic-flagged-without-resource). You cannot claim a synthetic voice without naming the voice profile.

The rendering is implemented, not deferred. renderShareCardHtml(card) produces an <article class="oshun-card"> with the title, summary, a provenance line, and visible badges for the grounding state (state · N cites) and each synthetic indicator (synth-voice / synth-avatar / synth-text). Every author-controlled field is HTML-escaped via escapeHtml to prevent XSS, and the full signal triple is embedded in an inline <script type="application/json"> that is escaped for safe HTML-script context. The badges are deliberately encoded so they survive screenshots, RSS scrapes, and reader-mode extractors. renderOEmbed(card) wraps the same HTML in a real OEmbedResponse (version: '1.0', type: 'rich', provider_name: 'Oshun', 540×320, cache_age 3600s) and adds extension fields oshun_provenance, oshun_grounding, oshun_synthetic so that external embedders are forced to either render the indicators or reject the embed. Two parity helpers, extractIndicatorsFromHtml and extractSignalsFromHtml, read the badges and the JSON back out — so a stripping attempt can be detected by a failed round-trip.

Version awareness: "this passage was updated since you saved it"#

version-awareness/version-awareness.ts backs the customer-facing prompt literally. The inputs are a CustomerSavedRef (artifactId, savedAtVersion: number | null, savedAtUnixSeconds) and the artifact's current head version.

  • evaluateStaleness(saved, currentVersion) returns a StalenessVerdict. A savedAtVersion of null means "intentionally tracks latest" → never stale. Otherwise, currentVersion > savedAtVersion is stale, and the verdict reports versionsBehind. (This is the consumer of the pinnedVersion field on CollectionArtifactRef.)
  • computePassageDiff(before, after, contextLines = 2) computes a line-oriented LCS (Myers-style) diff suited to prose. It returns DiffLine[] tagged context | added | removed with old/new line numbers, then trims to contextLines of surrounding context on each side of a change hunk — exactly the shape a diff UI renders directly.
  • buildVersionAwarenessReport(input) assembles the full VersionAwarenessReport: the saved and current versions, versionsBehind, the intermediateSummaries (VersionSummary change notes filtered to this artifact and the version range, sorted ascending), and the rendered diff. This is the "what changed since you saved this" narrative.

Because the diff is a genuine LCS — not a naive line-by-line compare — it correctly handles insertions, deletions, and moved-around prose without spurious "everything changed" noise.

Iris notebook is a first-class memory scope#

The architecture text ties curation notebooks to Iris memory, and that link is real at the Iris layer. IrisMemoryScope (libs/oshun/memory-iris/src/types.ts) is an 11-value union:

text
assistant_profile · session · scene · pose · conversation · domain
cross_domain · notebook · operator_copilot · tenant · admin_review

So notebook is a first-class scope — not merely "alongside profile/session/operator/tenant," but one peer among eleven (and the canonical names are assistant_profile and operator_copilot, stylized differently in older prose). The Iris adapter (memory-iris/src/adapter.ts) enforces that a notebook-scoped memory write requires a notebookId, warning "Notebook-linked memory requires notebookId to prevent cross-notebook recall" — the guard that keeps one notebook's recall from bleeding into another. The recall budget for the notebook surface is also distinct (the notebook surface gets a far higher per-recall budget than the assistant or shell surfaces). The binding from a curation NotebookCollection into an Iris notebook scope, however, is platform wiring that lives outside @oshun/customer-curation — see Iris Memory and Identity.

How the pieces compose: a save-and-share walkthrough#

  1. A member reading a Veritas story highlights a span. The reading surface builds an Annotation with a TextQuoteSelector and TextPositionSelector; validateAnnotation passes; it is stored.
  2. They save the story into a notebook collection. A CollectionArtifactRef is appended with pinnedVersion: 7 (freeze) or null (track latest) and an ordinal; validateCollection confirms no duplicate, valid ordinal, tenant match.
  3. They reorder items by dragging; reorderArtifact updates only the moved item's fractional ordinal, normalizing if gaps collapse.
  4. They share the notebook via link with comment permission. A SharePolicy gains a linkGrant (hashed token, tier comment); validateSharePolicy passes. A recipient hits the link; resolveSharePermission constant-time matches the token and returns { granted: true, tier: 'comment', via: 'link' }.
  5. To share a single story publicly, they generate a ShareCard; validateShareCard confirms the provenance/grounding/synthetic invariants; renderOEmbed produces the embeddable response with indicators intact.
  6. Weeks later the story is edited to version 9. evaluateStaleness flags the pinned save as stale (versionsBehind: 2); buildVersionAwarenessReport surfaces the diff and the intermediate change summaries.

Every step above is pure logic in @oshun/customer-curation. The steps that require durability — persisting the annotation, the collection, the share policy; replicating the reading cursor to another device; propagating a deletion as a tombstone; writing an audit-log entry — are outside this library and are the PARTIAL part of the audit's library-save-collection-share rating. That is the honest division of labor: contract-complete logic, thinner end-to-end persistence.

Tests#

The behaviors above are exercised by src/customer-curation.test.ts, covering sharing-permission isolation (including tenant isolation and link expiry/revocation), public-profile gating, annotation durability and selector re-anchoring across edits, smart-collection CNF evaluation and scoping, ordering normalization, version-diff correctness, and reading-progress/resume merge semantics. The validators are tested by asserting the exact typed error codes, and the share-card renderers by round-tripping indicators back out of the HTML.