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 Irisnotebookmemory scope. All of them live outside this pure library (in infra and other libs) or remain partial. The completeness audit (2026-06-22) flagslibrary-save-collection-shareandscene-keep-and-shareas 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, version0.1.0,private: true. - Dependencies: none at runtime.
package.jsondeclares onlydevDependencies(typescript,vitest) — it is a pure data-and-functions library withsideEffects: 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.tsre-exports six modules —collections,sharing,share-cards,annotations,bookmarks, andversion-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:
- notebook —
layoutmust be one ofNOTEBOOK_LAYOUTS(invalid-notebook-layout). - ritual-set — every
cadenceDaysOfWeekentry must be in0..6(invalid-cadence-day), no duplicates (duplicate-cadence-day), andcadenceLocalTimemust match^([01]\d|2[0-3]):([0-5]\d)$(invalid-cadence-time). - study-queue —
perDayCap, when set, must be an integer in1..200(per-day-cap-out-of-range). - reading-list —
targetFinishUnixSeconds, when set, must be an integer not earlier thancreatedAtUnixSeconds(invalid-target-finish). - saved-search — non-empty
queryText, non-emptyartifactKindFilterwhose entries are realARTIFACT_KINDS, and well-formed filter predicates (facetand operator ineq | 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.tsimplements gap-based drag-to-order.reorderArtifactplaces 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 anormalizeOrdinalspass when gaps collapse below threshold. It also exposesvalidateNestedParentandbuildBreadcrumbfor nested collections.templates.tsdefinesCollectionTemplate/CollectionTemplateItem,instantiateTemplate, andtemplateVisibleTofor the role-aware template gallery.import-from-another.tsimplementsimportCollection, which requires an authorization grant from the sharing layer: the input carries agrantwhosesourceCollectionIdmust match the source, whosegrantedToOwnerIdmust be the importer, and which must becopy-tier; cross-tenant copy must be opted in viaallowCrossTenantCopy. The result is a tagged union —{ ok: true, collection }carryingoriginCollectionId/originOwnerIdprovenance, 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 byorderByand applieslimit.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 whosetenantIddiffers, then admitting only owner-owned artifacts or artifacts whose${artifactKind}:${artifactId}key appears inscope.sharedArtifactKeys(which must already be authorized by the sharing layer) — before delegating toevaluateSmartCollection.
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:
exact— the savedpositionstill slices toquote.exactin the current text; reuse it verbatim.context—prefix + exact + suffixoccurs exactly once; re-anchor to theexactsubstring inside it.quote-only—exactalone occurs exactly once (lossy); use it.- 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 realOEmbedResponse(version: '1.0',type: 'rich',provider_name: 'Oshun',540×320,cache_age3600s) that carries the same provenance/grounding/synthetic blocks asoshun_*extension fields, forcing external embedders to either render the indicators or reject the embed.validateShareCardrejects unknown kinds, empty/non-HTTP canonical URLs, negative citation counts, retracted-source mismatches, and any synthetic flag set without its backing resource id;extractIndicatorsFromHtml/extractSignalsFromHtmlre-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/persistenceand inherit tombstone semantics; deletion propagates and audit-logs." In reality@oshun/customer-curationdeclares no@oshun/persistencedependency, no source file imports it, and nothing insrc/references "tombstone" or writes an audit log.@oshun/persistencedoes 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 carrydeletedAtUnixSeconds, collections carryarchivedAtUnixSeconds— but propagation is not this lib's job.) - Nisaba
Notebookcontract substrate. The hub framed personal notebooks as built on "Nisaba's notebook schema (Notebookcontract)." That contract is real and lives inlibs/contracts/src/common/notebook.ts(NotebookKindSchema=research | reflection | study | evidence | mixed | investigation, plusNotebookDomainSchema,NotebookStatusSchema,NotebookVisibilitySchema,NotebookMethodologySchema,NotebookViewSchema, and the fullNotebookSchema). Butcollections/collections.tsdefines its ownNotebookCollection(kind: 'notebook',layout: linear | kanban | grid) and does not import the contractsNotebookschema. The two are separate models in separate libraries; the substrate relationship is a design intent, not a realized import. - Iris
notebookmemory scope. This one is directionally correct but was stated imprecisely.notebookis 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 thatnotebook-scoped memory requires anotebookId"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, notprofile;operator_copilot, notoperator-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"#
- A reader saves a Nisaba passage. A
CollectionArtifactRefis appended to aNotebookCollection(or areading-list), withpinnedVersionset to the passage's current version (ornullto track latest).validateCollectionrejects duplicate ordinals, tenant mismatches, or unknown artifact kinds. - Later, the passage is re-edited upstream.
evaluateStaleness(savedRef, currentVersion)returns{ stale: true, versionsBehind };buildVersionAwarenessReportproduces the trimmed Myers diff the reader sees as "show diff." - The reader curates a
smart-collection("all my grounded Nisaba passages tagged exegesis").validateSmartCollectionRuleschecks the CNF;materializeSmartCollectionForOwnerscopes candidates to the owner's tenant and authorized shared keys beforeevaluateSmartCollectionfilters and orders them. - The reader shares the collection by link with
commenttier. TheSharePolicygets aLinkGrant; a visitor's request resolves throughresolveSharePermission(constant-time token check, expiry/revocation, tenant isolation) to{ granted: true, tier: 'comment', via: 'link' }. - A friend imports it.
importCollectionrequires acopy-tier grant matching the source; on success it returns a new collection stamped withoriginCollectionId/originOwnerId. - The reader embeds a single passage as a share card.
renderShareCardHtml/renderOEmbedemit 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.
Related#
- Search, Discovery, and Knowledge Graph — reads saved-searches, collections, and notebooks back as ranking and recommendation signals.
- Iris — Assistant Memory Substrate — owns the
notebookmemory scope and its cross-notebook-recall guard. - Customer-Facing Domains — the domains whose artifacts
(
ARTIFACT_KINDS) curation references. - Trust, Safety, and Privacy — synthetic-content indicators and sharing-abuse controls.
V1/features.md§ Customer Curation, Notebooks, Collections, and Sharing — product scope.- Hub: ../ARCHITECTURE.md; backlog
V1/TODOS.md§ 17.