# Customer Curation, Notebooks, Collections, and Sharing

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](./search-discovery-recommendations.md)
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](../architecture/customer-curation.md).

## 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](./iris-memory-identity.md), 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](./scene-sharing-distribution.md)
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:

```
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 | null` — **`null` 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](./sophia-grounding.md) and the
provenance signals from [Isis Generation Control](./isis-generation-control.md).

## 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                                                                                                    |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `owner` → `copy`                                                     | 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`):

```
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. **`context`** — `prefix + exact + suffix` occurs _exactly once_ in the new
   text. Anchor inside it.
3. **`quote-only`** — `exact` 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**:

- **`ProvenanceBlock`** — `sourceAttribution`, `authorIds`, `evidencePackId`,
  `citationTrailId`, `publishedAtUnixSeconds`.
- **`GroundingBlock`** — `state`
  (`grounded | partial | ungrounded | abstained | retracted-source`),
  `citationCount`, `retrievalMethods`
  (`bm25 | dense | hybrid | graph | structured | tool | hierarchical`),
  `retractedSourceCount`.
- **`SyntheticIndicatorBlock`** — `voiceSynthesized`, `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:

```
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](./iris-memory-identity.md).

## 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.

## Related

- [Customer Curation](../architecture/customer-curation.md) — the architecture
  deep-dive on the same surface (shipping vs. spec, where the logic lives).
- [Iris Memory and Identity](./iris-memory-identity.md) — the `notebook` memory
  scope and recall budgets.
- [Search, Discovery, Recommendations, and Knowledge Graph](./search-discovery-recommendations.md)
  — reads collections, notebooks, and saved searches back.
- [Keep, Share, Shareability, Takedown, and Lineage](./scene-sharing-distribution.md)
  — the Living Scenes "keep and share" counterpart.
- [Sophia Grounding](./sophia-grounding.md) and
  [Isis Generation Control](./isis-generation-control.md) — the grounding-state
  and synthetic-indicator vocabularies share cards and smart collections read.
- [Privacy, Consent, Data Portability, and User Controls](./privacy-consent-portability.md)
  — annotation/collection export and customer data controls.
- [Subsystem Glossary](./glossary.md) and
  [Product Surfaces](./product-surfaces.md) — orientation; hub:
  [../features.md](../features.md).
