Oshun Platform · Architecture

Customer Curation

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

11sections15 minread3tables

On this page

Customer Curation is the V1 personal-knowledge layer: the 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 customer-facing domain rather than belonging to any one of them, and it is the substrate that Search and Recommendations read back from (see Search, Discovery, and Knowledge Graph). This page sits among the cross-cutting product-surface deep-dives in the V1 architecture set hubbed at ../ARCHITECTURE.md; product scope lives in V1/features.md § Customer Curation, Notebooks, Collections, and Sharing, and the backlog is V1/TODOS.md § 17.

Read this page for what is shipping vs. spec. The curation logic is real and non-stub: it ships as a pure, dependency-free contracts-and-logic library, @oshun/customer-curation (libs/oshun/customer-curation), whose seven collection kinds, sharing-permission resolver, smart-collection rule engine, W3C-style annotation anchoring, oEmbed share cards, and version-diff engine are all implemented with domain-specific algorithms and exercised by a large test file (src/customer-curation.test.ts). What is aspirational relative to this library is the platform plumbing the architecture text attributes to it: durable database persistence, tombstone-deletion propagation, audit-logging, cross-device progress sync, and the binding to the Iris notebook memory scope. All of them live outside this pure library (in infra and other libs) or remain partial. The completeness audit (2026-06-22) flags 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.

Where the logic actually lives#

Everything described below is implemented in one package:

  • Package: @oshun/customer-curation, version 0.1.0, private: true.
  • Dependencies: none at runtime. package.json declares only devDependencies (typescript, vitest) — it is a pure data-and-functions library with sideEffects: false. Notably it does not depend on @oshun/persistence, does not import any database client, and contains no code that references "tombstone" or writes an audit log. Those behaviors, where they exist, belong to other libraries wired at the call site.
  • Barrel: src/index.ts re-exports six modules — collections, sharing, share-cards, annotations, bookmarks, and version-awareness.
Module Path Responsibility
collections src/collections/ Seven collection kinds, validation, smart collections, ordering, templates, import
sharing src/sharing/sharing.ts Visibility/permission matrix and viewer-resolution
share-cards src/share-cards/share-cards.ts Embeddable cards, oEmbed and HTML rendering, indicator preservation
annotations src/annotations/annotations.ts Highlights, notes, threads, citations, selector-chain anchoring, exports
bookmarks src/bookmarks/bookmarks.ts Per-device cursors, EWMA pace, time-to-finish, reading-list progress
version-awareness src/version-awareness/version-awareness.ts Staleness verdicts and Myers/LCS passage diff

Collections — seven kinds, one taxonomy#

The heart of the library is a single collection taxonomy, defined in src/collections/types.ts. COLLECTION_KINDS enumerates six canonical container kinds — notebook, collection, study-queue, ritual-set, reading-list, and saved-search — and collections.ts adds an OrderedCollection for the bare collection kind, so seven concrete CustomerCollection shapes exist in total. Every kind extends a shared CollectionBase (collectionId, kind, ownerId, tenantId, parentCollectionId, title, timestamps, archivedAtUnixSeconds, and an ordered items: CollectionArtifactRef[] list). Each kind then layers on kind-specific schema fields that the prose "study queues, ritual sets, reading lists, saved searches" hides:

Kind Interface Kind-specific fields
notebook NotebookCollection layout: 'linear' | 'kanban' | 'grid' (NOTEBOOK_LAYOUTS)
collection OrderedCollection (ordered set; base only)
study-queue StudyQueueCollection masteryTarget: 'familiar' | 'developing' | 'proficient' | 'master', perDayCap: number | null
ritual-set RitualSetCollection cadenceLocalTime: string | null (HH:MM), cadenceDaysOfWeek: ReadonlyArray<0..6> mask (0 = Sun … 6 = Sat)
reading-list ReadingListCollection targetFinishUnixSeconds: number | null
saved-search SavedSearchCollection queryText, filters AST, artifactKindFilter, lastExecutedCursor

Membership is by typed reference, not by copy. A CollectionArtifactRef records the artifactId, an artifactKind drawn from the large cross-domain ARTIFACT_KINDS union (e.g. nisaba.edition, veritas.claim, metis.lesson, tara.ritual-script, nyx.sky-event, arete.habit, oshun.generated-artifact), a pinnedVersion: number | null (where null means always-latest and a number pins the exact version saved), a free-form note, an ordinal, and addedAtUnixSeconds. Pinning is what makes the version-awareness feature later in this page possible.

Validation is the contract#

validateCollection(collection, { tenantId }) returns a typed CollectionValidationError[] — never throws — so callers compose it into their own request handling. The error union is exhaustive and kind-aware. Beyond the generic checks (empty-title, tenant-mismatch, created-after-updated, archive-before-created, duplicate-artifact and duplicate/invalid-ordinal, invalid-pinned-version), it enforces each kind's rules:

  • notebooklayout must be one of NOTEBOOK_LAYOUTS (invalid-notebook-layout).
  • ritual-set — every cadenceDaysOfWeek entry must be in 0..6 (invalid-cadence-day), no duplicates (duplicate-cadence-day), and cadenceLocalTime must match ^([01]\d|2[0-3]):([0-5]\d)$ (invalid-cadence-time).
  • study-queueperDayCap, when set, must be an integer in 1..200 (per-day-cap-out-of-range).
  • reading-listtargetFinishUnixSeconds, when set, must be an integer not earlier than createdAtUnixSeconds (invalid-target-finish).
  • saved-search — non-empty queryText, non-empty artifactKindFilter whose entries are real ARTIFACT_KINDS, and well-formed filter predicates (facet and operator in eq | in | gte | lte | contains).

The kinds also carry real behavioral helpers, not just shape: nextStudyQueueItem ranks queue items by a mastery ladder (novice < familiar < developing < proficient < master) and returns the first item still below masteryTarget, breaking ties by ordinal; pendingReadingListItems filters a reading list to artifacts whose progress fraction is < 1, ordered by ordinal; and ritualSetDueToday answers the day-of-week-mask question (an empty mask means every day).

Ordering, nesting, templates, import#

Four supporting files round out the collections module — the features promised by "drag-and-drop ordering, nested collections, … templates, and import from another collection":

  • ordering.ts implements gap-based drag-to-order. reorderArtifact places an item immediately after another (or at the front) by computing a new ordinal — midpoint between neighbors, or ± DEFAULT_ORDINAL_GAP (1024) at the edges — and triggers a normalizeOrdinals pass when gaps collapse below threshold. It also exposes validateNestedParent and buildBreadcrumb for nested collections.
  • templates.ts defines CollectionTemplate / CollectionTemplateItem, instantiateTemplate, and templateVisibleTo for the role-aware template gallery.
  • import-from-another.ts implements importCollection, which requires an authorization grant from the sharing layer: the input carries a grant whose sourceCollectionId must match the source, whose grantedToOwnerId must be the importer, and which must be copy-tier; cross-tenant copy must be opted in via allowCrossTenantCopy. The result is a tagged union — { ok: true, collection } carrying originCollectionId / originOwnerId provenance, or { ok: false, reason: 'grant-mismatch' | 'insufficient-tier' | 'tenant-isolation' }.

Smart collections — a real rule engine#

The features line "smart collections (rule-based)" undersells what src/collections/smart-collections.ts ships: a small but real predicate engine in conjunctive normal form (AND-of-OR). A SmartCollectionRules has allOf: SmartCollectionRuleGroup[] (every group must match), each group an anyOf: SmartCollectionPredicate[] (any predicate inside it matches), plus a limit and an orderBy (recency-desc | recency-asc | relevance-desc | alphabetical). The SmartCollectionPredicate union covers artifact-kind eq/in, tenant/owner equality, tag-in/tag-contains, created-at gte/lte, domain-eq (over a SmartCollectionDomain of the ten platform domains), synthetic-indicator-eq, grounding-state-eq (grounded | partial | ungrounded | abstained | retracted-source), and a case-insensitive text-contains over title or description.

Three public functions drive it:

  • validateSmartCollectionRules(rules) returns typed errors (empty-rule-set, empty-rule-group, invalid-limit, empty-text-query, empty-tag-list, unknown-artifact-kind) so a rule set is checked before it is ever evaluated.
  • evaluateSmartCollection(rules, candidates) filters candidates through the CNF, then sorts by orderBy and applies limit.
  • materializeSmartCollectionForOwner(rules, candidates, scope) is the scoped entry point that ad-hoc callers should use: it keeps tenant/owner isolation out of caller hands by first dropping any candidate whose tenantId differs, then admitting only owner-owned artifacts or artifacts whose ${artifactKind}:${artifactId} key appears in scope.sharedArtifactKeys (which must already be authorized by the sharing layer) — before delegating to evaluateSmartCollection.

A smartRulesEqual helper canonicalizes rule groups (sorting predicates and tag-in/artifact-kind-in value lists) so two rule sets can be compared for equivalence — used to dedupe smart collections in a library and to short-circuit re-evaluation when nothing changed.

Sharing — visibility × permission, with a viewer resolver#

src/sharing/sharing.ts models sharing as two orthogonal axes. Visibility (SHARE_VISIBILITY, least → most permissive) is private, named-users, link, public-profile; permission grants (SHARE_PERMISSION) are view, comment, copy. A SharePolicy carries the visibility plus the named-user grants, tokenized link grants, and the public-profile surface.

The core is resolveSharePermission(input), which decides what a specific viewer gets. Viewers are typed — owner, authenticated (with userId and tenantId), link (with providedTokenHash), or public-profile-visitor — and the resolver returns either { granted: true, tier, via } (where via is 'owner' | 'named' | 'link' | 'public-profile') or { granted: false, reason }. The denial reasons are an honest enumerable set:

ShareDenialReason When it fires
private Visibility is private (or wrong viewer for the visibility)
not-in-named-list Authenticated viewer absent from namedUserGrants
link-expired Link grant past expiresAtUnixSeconds
link-revoked Link grant has a revokedAtUnixSeconds
link-token-mismatch No link grant matches the provided token hash
public-profile-not-enabled public-profile visibility but surface disabled
public-profile-missing-slug Surface enabled but no profileSlug
tenant-isolation Authenticated viewer's tenantId ≠ policy's

Security-relevant details are real, not hand-waved. The owner always resolves to copy tier. Link verification uses a timingSafeEqual constant-time comparison (length-masked to avoid early-return timing leaks) — the module compares token hashes but explicitly leaves crypto-secure generation to the caller. A named-user grant takes precedence even when visibility is link or public-profile, so an explicitly named viewer is never down-graded by a broader surface. Companion functions cover the lifecycle: validateSharePolicy (duplicate grants, expiry/revocation before creation, public-profile enabled without slug), revokeLinkGrant, revokeNamedUserGrant, sharePermissionSatisfies / sharePermissionRank (view = 1, comment = 2, copy = 3), and publicProfileSurfacedSlugs to build a member's public profile page.

Annotations — W3C-style selector chains#

src/annotations/annotations.ts is far more concrete than "highlights, threaded notes, citations." Annotations attach to one of four reading surfaces (ANNOTATION_SURFACES: nisaba.edition, veritas.story, metis.lesson, tara.transcript) and come in four kinds (ANNOTATION_KINDS: highlight, note, thread-comment, citation). Spans use the same selector-chain model as Hypothes.is / W3C Web Annotations: an AnnotationTarget carries a TextQuoteSelector (exact, prefix, and suffix) and a TextPositionSelector (start/end offsets), so anchors survive small edits.

resolveAnnotationAnchor(annotation, currentText) is the recovery algorithm, with a documented strategy precedence:

  1. exact — the saved position still slices to quote.exact in the current text; reuse it verbatim.
  2. contextprefix + exact + suffix occurs exactly once; re-anchor to the exact substring inside it.
  3. quote-onlyexact alone occurs exactly once (lossy); use it.
  4. Otherwise unresolved (null) — the library refuses to guess rather than anchor to the wrong place.

Threads are validated for durability across a batch by validateAnnotationThreadIntegrity, which catches thread-parent-not-found, thread-parent-cross-tenant, thread-parent-cross-owner, thread-parent-not-threadable, thread-parent-target-mismatch, and thread-parent-cycle. buildAnnotationThread assembles a tree while keeping a deleted node only if it still has live children (preserving thread cohesion), and deriveBacklinks materializes the cross-artifact backlinks the features doc mentions: each non-deleted citation annotation yields an AnnotationBacklink record on its target.

Export is implemented for all three formats: exportAnnotationsAsJson (canonical, stable-sorted), exportAnnotationsAsMarkdown (grouped by artifact, chronological), and exportAnnotationsAsCsv (RFC-4180 escaping, deleted rows excluded so the export matches what the reader sees).

Share cards — provenance that survives the embed#

src/share-cards/share-cards.ts implements embeddable cards for the kinds in SHARE_CARD_KINDS (veritas.story, veritas.claim, nisaba.passage, nyx.sky-event, tara.meditation, metis.module, metis.lesson-module). A ShareCard bundles three signal blocks that must never be stripped during an embed: a ProvenanceBlock (source attribution, author ids, evidence-pack and citation-trail refs, publish time), a GroundingBlock (state, citation count, retrievalMethods such as bm25/dense/hybrid/graph, retracted-source count), and a SyntheticIndicatorBlock (voice/avatar/text synthesized flags plus the persona/voice/avatar resource ids).

The architecture text notes the indicators are "preserved" but omits that the rendering is implemented:

  • renderShareCardHtml(card) emits an <article class="oshun-card"> with every author-controlled field HTML-escaped (escapeHtml) to prevent XSS, and encodes provenance/grounding/synthetic state as visible badges plus an embedded <script type="application/json" class="oshun-card__signals"> block — so the indicators survive screenshots, RSS scrapes, and reader-mode extractors.
  • renderOEmbed(card) returns a real OEmbedResponse (version: '1.0', type: 'rich', provider_name: 'Oshun', 540×320, cache_age 3600s) that carries the same provenance/grounding/synthetic blocks as oshun_* extension fields, forcing external embedders to either render the indicators or reject the embed.
  • validateShareCard rejects unknown kinds, empty/non-HTTP canonical URLs, negative citation counts, retracted-source mismatches, and any synthetic flag set without its backing resource id; extractIndicatorsFromHtml / extractSignalsFromHtml re-parse rendered output for strip-detection parity checks.

Bookmarks — per-device cursors and pace-aware estimates#

src/bookmarks/bookmarks.ts backs "bookmark and reading-list with reading progress, time-to-finish estimates, and resume state across devices." A Bookmark holds a list of DeviceCursors, each a (deviceId, locator, fraction, lastSeenUnixSeconds). Locators are typed (READ_LOCATOR_KINDS): text-offset (character offset), timecode (seconds), or page-section (page plus optional section). updateCursor merges a device update, ignoring stale or regressive cursors; resumeCursor returns the most-recently-seen device cursor (the "where did I leave off on my phone?" answer) and farthestCursor returns the most-advanced one.

Time-to-finish is a real estimate, not a constant. computePace(events, now, windowSeconds = 14 days) computes an exponentially weighted units-per-minute over a 14-day window with a 7-day half-life, so recent activity dominates without the estimate becoming jumpy. estimateSecondsToFinish divides remaining units (totalUnits × (1 − fraction)) by the measured pace, falling back to defaultUnitsPerMinute when there are no samples, and summarizeReadingListProgress rolls a whole reading list into { totalItems, completedItems, overallFraction, secondsToFinish, resume } — returning Infinity for time-to-finish when any item lacks a unit count rather than fabricating a number.

Honest caveat — "across devices" is modeling, not sync. This module gives every operation the per-device cursor structure and the merge/resume semantics needed for cross-device resume, but the actual cross-device synchronization (transport, conflict propagation, persistence) lives outside this pure library. The completeness audit's PARTIAL marks reflect exactly this gap between contract-complete logic and a fully persisted, synced flow.

Version awareness — "this changed since you saved it"#

src/version-awareness/version-awareness.ts realizes the customer-facing "this passage was updated since you saved it — show diff" feature concretely. A CustomerSavedRef records savedAtVersion (a number, or null to mean "intentionally track latest") and savedAtUnixSeconds. evaluateStaleness returns a StalenessVerdict{ stale: false } when tracking latest or when the current version isn't ahead, otherwise { stale: true, currentVersion, versionsBehind }.

The diff is a real Myers-style line-oriented LCS (computePassageDiff): it builds the LCS table over \n-split lines, backtracks into a DiffLine[] stream of context/added/removed lines (each with old/new line numbers), and trims to contextLines (default 2) around each change hunk so the surfaced diff reads like prose, not a wall of unchanged text. buildVersionAwarenessReport assembles the full UI payload — savedAtVersion, currentVersion, versionsBehind, the filtered+sorted intermediateSummaries (only versions strictly after the saved one, up to the current head), and the diff — giving the reader both a "what changed" narrative and the line-level diff.

Platform integration — what's claimed vs. what's wired#

The architecture hub historically attributed several platform-integration behaviors to this surface that the library itself does not implement. Reconciled against the code:

  • Persistence / tombstones / audit-log. The hub text said "per-domain notebook/collection tables persist through @oshun/persistence and inherit tombstone semantics; deletion propagates and audit-logs." In reality @oshun/customer-curation declares no @oshun/persistence dependency, no source file imports it, and nothing in src/ references "tombstone" or writes an audit log. @oshun/persistence does exist as a library (libs/oshun/persistence, with Prisma), but any persistence/tombstone/audit behavior is wired elsewhere, not inside this contracts library. (Soft-delete is modeled at the data level — annotations carry deletedAtUnixSeconds, collections carry archivedAtUnixSeconds — but propagation is not this lib's job.)
  • Nisaba Notebook contract substrate. The hub framed personal notebooks as built on "Nisaba's notebook schema (Notebook contract)." That contract is real and lives in libs/contracts/src/common/notebook.ts (NotebookKindSchema = research | reflection | study | evidence | mixed | investigation, plus NotebookDomainSchema, NotebookStatusSchema, NotebookVisibilitySchema, NotebookMethodologySchema, NotebookViewSchema, and the full NotebookSchema). But collections/collections.ts defines its own NotebookCollection (kind: 'notebook', layout: linear | kanban | grid) and does not import the contracts Notebook schema. The two are separate models in separate libraries; the substrate relationship is a design intent, not a realized import.
  • Iris notebook memory scope. This one is directionally correct but was stated imprecisely. notebook is a first-class Iris memory scope: IrisMemoryScope (libs/oshun/memory-iris/src/types.ts:45) is an 11-value union — assistant_profile, session, scene, pose, conversation, domain, cross_domain, notebook, operator_copilot, tenant, admin_review — and the Iris adapter (libs/oshun/memory-iris/src/adapter.ts) enforces that notebook-scoped memory requires a notebookId "to prevent cross-notebook recall." So the older note's enumerated peer list (profile/session/operator-copilot/tenant) was incomplete, and the names are stylized differently from code (assistant_profile, not profile; operator_copilot, not operator-copilot). See Iris — Assistant Memory Substrate.

The takeaway: @oshun/customer-curation is the contracts-and-logic layer, and V1/features.md describes it accurately as self-contained customer surfaces. The architecture hub over-attributed integration (persistence, Notebook substrate, Iris binding) to this surface; those layers are real where they exist, but they live in other libs and infra, and the end-to-end persisted flow is still partial per the 2026-06-22 completeness audit.

Data-flow walkthrough: "Keep this passage, then share my collection"#

  1. A reader saves a Nisaba passage. A CollectionArtifactRef is appended to a NotebookCollection (or a reading-list), with pinnedVersion set to the passage's current version (or null to track latest). validateCollection rejects duplicate ordinals, tenant mismatches, or unknown artifact kinds.
  2. Later, the passage is re-edited upstream. evaluateStaleness(savedRef, currentVersion) returns { stale: true, versionsBehind }; buildVersionAwarenessReport produces the trimmed Myers diff the reader sees as "show diff."
  3. The reader curates a smart-collection ("all my grounded Nisaba passages tagged exegesis"). validateSmartCollectionRules checks the CNF; materializeSmartCollectionForOwner scopes candidates to the owner's tenant and authorized shared keys before evaluateSmartCollection filters and orders them.
  4. The reader shares the collection by link with comment tier. The SharePolicy gets a LinkGrant; a visitor's request resolves through resolveSharePermission (constant-time token check, expiry/revocation, tenant isolation) to { granted: true, tier: 'comment', via: 'link' }.
  5. A friend imports it. importCollection requires a copy-tier grant matching the source; on success it returns a new collection stamped with originCollectionId / originOwnerId.
  6. The reader embeds a single passage as a share card. renderShareCardHtml / renderOEmbed emit the card with provenance/grounding/synthetic badges that downstream embedders cannot silently strip.

The logic of every step above is shipping code with tests. The persistence linking steps 1–6 into durable, audit-logged, cross-device-synced records is the part the completeness audit marks PARTIAL.