Privacy in Oshun V1 spans consent, retention, residency, deletion, export,
operator access, and disclosure across the customer, creator, tenant, and
institutional contexts. It is the user-facing half of the governance area: where
Review, Compliance, and Trust & Safety governs what
the product does to content, this surface governs what the product may do
with a person's data — and gives that person the controls to grant, withdraw,
inspect, export, and erase it. The deterministic core lives in @oshun/privacy
(libs/oshun/privacy/); its modules are pure-function state machines and
taxonomies, with the actual enforcement (live persistence, real fan-out across
domains, immutable audit storage) supplied by the runtime that composes them.
The backlog for this area is §22; the feature hub is
../features.md; the architecture companions are
Security, Privacy, and Compliance
and Trust & Safety and Privacy.
How This Is Built — Real vs. Aspirational#
The honest boundary matters here, so it is stated up front. The @oshun/privacy
library is implemented and verified as a set of deterministic taxonomies and
state machines: the consent taxonomy and transition/audit logic
(consent/consent.ts), the export-bundle builder with a SHA-256 integrity
manifest (export-deletion/export.ts), the deletion state machine with
retention constants and legal-hold gating (export-deletion/deletion.ts), the
residency router with cross-region-blocked verdicts and propagation tracking
(residency/residency.ts), the DSAR and operator-access workflow
(dsar/dsar.ts), the privacy-center model
(privacy-surface/privacy-surface.ts), and the regulatory regime / breach /
audit-completeness logic (compliance/compliance.ts). These are exported
through the package root @oshun/privacy (libs/oshun/privacy/src/index.ts).
What these pure cores deliberately do not do is touch storage themselves.
The one intentional exception is the account-deletion fan-out:
deletion-erasers/generated-artifact-eraser.ts reaches the Isis
GeneratedOutput store through a lazy, injectable seam to hard-delete a
subject's generated outputs (deleteMany by ownerId), returning the row count
rather than fabricating a result. Everywhere else, the cores describe and
validate transitions; the runtime is responsible for real classifiers, live
audit-platform persistence (@oshun/audit-platform owns immutable storage), and
real DSAR fan-out across domains. Consent and memory ownership are shared with
Iris — see Iris Memory and Identity — and the
crisis-frame cascade that privacy and safety both depend on is described in
Lilith Persona Policy and
Review, Compliance, and Trust & Safety. Nothing in
this library reads as result-faking: the cryptographic-shred flag, for example,
is recorded verbatim from the executor that performed the deletion and can never
claim a shred that did not happen.
Consent Taxonomy#
Consent (§22.1) is organized into nine consent families, enumerated in
CONSENT_FAMILIES (consent/consent.ts): memory, voice, avatar,
synthetic-media, notifications, privacy-surface, support, research,
and educational-context. Each family carries its own purpose vocabulary so a
consent is never a vague all-or-nothing toggle — it names exactly what is being
permitted.
| Family | Real key shape / purpose enum | Notes |
|---|---|---|
memory |
MEMORY_SCOPES = profile | session | notebook; MEMORY_SENSITIVITY = baseline | sensitive |
Scope × sensitivity; sensitive defaults off. Owned jointly with Iris. |
voice |
VOICE_CONSENT_PURPOSES = recording, cloning-own-voice, cloned-voice-in-personas, voice-data-analytics |
Each separately granular. |
avatar |
AVATAR_CONSENT_PURPOSES = likeness-capture, generated-likeness, animation-rights, tenant-scoped-distribution |
Likeness rights are split from distribution rights. |
synthetic-media |
SYNTHETIC_MEDIA_PURPOSES = ai-generation-on-uploads, derivative-works, tenant-publication |
Generation on user inputs vs. publishing the result. |
notifications |
per channel (in-app, push, email, sms, voice, webhook) × domain × severityMin (info/standard/important/critical) |
Granular down to per-domain, per-severity. |
privacy-surface |
PRIVACY_SURFACE_PURPOSES = research-data-sharing, analytics-granularity-coarse, analytics-granularity-fine, third-party-processor-inclusion, training-data-inclusion, cross-tenant-data-sharing |
The high-sensitivity surfaces (training-data inclusion, cross-tenant sharing) default off. |
support |
SUPPORT_CONSENT_PURPOSES = agent-screen-share, agent-memory-access, session-recording |
Granted only for the troubleshooting window. |
research |
per studyId |
Per-study granularity; never a blanket opt-in. |
educational-context |
EDUCATIONAL_PURPOSES = assignment-data-sharing-with-teacher, assignment-data-sharing-with-institution, standards-reporting |
Institutional Metis-specific; see Metis — Education and Tutoring. |
Each consent is keyed by a discriminated-union ConsentKey, and every key
serializes to a stable, human-readable string via canonicalKeyString — for
example memory:profile:sensitive, voice:cloning-own-voice,
privacy-surface:training-data-inclusion, or
notifications:push:tara:important. That canonical string is what the audit
log, the withdrawal cascade, and the per-feature reveal all reference, so a
single consent is identifiable across every surface that touches it.
Reconciling the architecture doc. Some companion text attributes consent ownership solely to Iris (
@oshun/memory-iris), stating that "every consent change emits aConsentRecordevent." That is only half the picture: a full consent taxonomy and theConsentRecordtype also live here inlibs/oshun/privacy/src/consent/consent.ts(CONSENT_FAMILIES,MEMORY_SCOPES, the per-family purpose enums, the transition validator). Iris owns the memory consent surface and recall; the privacy lib owns the cross-family consent core. Both are real, and they share the same record shape.
Granularity, Default State, and Withdrawal#
Consent granularity (§22.2) is enforced, not merely promised. Three rules are
encoded directly in consent/consent.ts:
- Per-feature opt-in with no bundled sensitive consent. Every key is
independently toggleable.
validateConsentRecordrejects anyConsentRecordwhose key is sensitive (isSensitiveKey) and whosebundledWitharray is non-empty, returning abundled-sensitive-consenterror. There is no "accept all" that can sweep a sensitive category into a bundle. - Default state derived from sensitivity.
defaultState(key)returnsdeniedfor every sensitive case — sensitive memory, all ofvoice/avatar/synthetic-media,support,research,educational-context, and the sensitiveprivacy-surfacepurposes (research-data-sharing,analytics-granularity-fine,third-party-processor-inclusion,training-data-inclusion,cross-tenant-data-sharing). It returnsgrantedonly for baseline memory, coarse analytics, and transactionalnotifications. Training-data inclusion and cross-tenant sharing are off until the user explicitly opts in. - Audited transitions.
transitionConsentis the only sanctioned way to change a consent. It computespriorStatefrom the current record (granted/denied/unset), rejects an inconsistent prior (a mismatched user, tenant, or key producesprior-state-inconsistent), requires a non-emptyreasonCode, and on success returns both the newConsentRecordand aConsentTransitionAuditcarryingkeyString,priorState,newState,atUnixSeconds,actorId, andreasonCode. Every state change therefore produces an audit entry by construction.
Withdrawal cascade#
Withdrawing a consent is not a single bit-flip; it propagates to everything
derived from the prior grant. planWithdrawalCascade builds a WithdrawalJob
whose steps each name a WithdrawalCascadeKind from WITHDRAWAL_CASCADE_KINDS:
evict-from-memory, remove-cloned-voice-instance,
remove-cloned-avatar-instance, unpublish-derivative,
remove-from-training-set, remove-from-research-cohort, and
unshare-with-teacher. The plan distinguishes immediate effects from
propagating ones exactly as the spec requires: when memory consent is
withdrawn, effectiveImmediately contains evict-from-memory (storage and
recall stop at once), while the remaining downstream artifacts become tracked
steps with estimatedCompletionUnixSeconds. The job's etaUnixSeconds is the
max across them, so the UI can show "withdrawal effective now; derivative
cleanup completes by …". Each step starts pending and advances through
in-flight → completed / failed, giving the privacy center a real job state
to render rather than an unverifiable "done."
Region, Residency, and Routing#
Residency (§22.3, residency/residency.ts) keeps a region's data on that
region's plane unless a sanctioned exception applies. Each launch region is a
RegionDeclaration with a primaryPlaneId and an optional failoverPlaneId,
and every governed resource carries a ResidencyAssignment recording its
homeRegionId.
The router routeRead returns one of three ResidencyRoutingResult verdicts:
{ route: 'primary', planeId }— the caller is in the resource's home region and the primary plane is up.{ route: 'failover', planeId }— the caller is home, the primary is down, and a failover plane exists.{ route: 'cross-region-blocked', reason: 'residency-violation' }— the caller's region differs from the resource's home region (or the home region is unknown). This is a hard block, not a degraded read: data does not leave its region just because a caller asked from elsewhere.
Cross-region movement happens only through an explicit, two-party exception.
CROSS_REGION_EXCEPTION_KINDS enumerates the three lawful reasons:
legal-hold, security-incident, and operator-explicit-with-consent.
approveCrossRegionException enforces that the rationale is non-empty
(rationale-empty), that an operator-explicit-with-consent exception carries
a customerConsentRecordId (consent-required), and that the approver is not
the requester (self-approval). Once approved, the move is tracked to
completion: startPropagationTracker enumerates replica destinations,
advanceReplicaState walks each through enqueued → in-flight →
completed/failed (illegal transitions are rejected), and
evaluatePropagationCompletion reports allTerminal only when no destination
is still in flight or enqueued. applyTrackerToExceptionRequest flips the
request's completionTracked flag to true solely when every replica is
terminal — and the doc comment notes that this flag is exactly the signal the
compliance dashboard alerts on when it stays false.
Two further residency guarantees are concrete functions, not prose:
residencyAwareCacheKey({ baseKey, homeRegionId }) prefixes the home region
({region}::{baseKey}) so a cache entry from one region can never be served to
a caller in another, and logSegmentForRegion(regionId, logName) returns
logs/{regionId}/{logName} so logs are segregated by region by construction.
Subprocessor change notices are generated by buildSubprocessorChangeNotices,
which diffs a prior and current subprocessor list and emits added / removed
/ purpose-changed SubprocessorChange entries, each carrying a
customerNotificationRequiredByUnixSeconds deadline (per-tenant for
institutional contracts).
Export, Deletion, and Retention#
Export bundles#
Export (§22.4, export-deletion/export.ts) covers eleven EXPORT_SCOPES:
profile, conversation-history, memory, notebooks, journals,
ritual-completions, claims-passages-saved, generated-artifacts,
audit-visible-to-user, support-history, and billing-receipts. Three
EXPORT_FORMATS are supported — json (canonical), csv (structured scopes
only), and markdown (prose scopes, plus profile). defaultFormatFor picks
JSON for structured scopes and Markdown for prose scopes, and
isFormatValidForScope rejects, e.g., CSV for a free-text journal.
Every export ships with a tamper-evident integrity manifest. buildExportBundle
canonicalizes the bundle (sorting scopes and per-entry digests) and computes a
SHA-256 bundleDigest over bundle identity, scopes, format, and the entry list
(via @noble/hashes), recording
{ version: 1, algorithm: 'sha-256', bundleDigest, perEntryDigests, producedAtUnixSeconds }.
Each ExportEntry also carries its own consentContext — the granted/denied
state of the consents relevant to that data — so the export is self-describing
about what permissions governed the data it contains. verifyExportBundle
recomputes the digest and checks the per-entry manifest (catching
missing-entry, digest-mismatch, and unexpected-entry); callers must reject
on mismatch before serving the bundle to the data subject. This same
verification backs DSAR portability.
Deletion state machine#
Deletion (export-deletion/deletion.ts) is a strict state machine over
DELETION_SCOPES (profile, the three memory.* scopes,
conversation-history, notebooks, journals, generated-artifacts,
full-account). A DeletionRequest moves through the states pending →
soft-deleted → hard-deleted, with cancelled and blocked-by-hold as
off-ramps:
enqueueDeletionvalidates the soft-delete window and checks active legal holds; if any apply, the request is createdblocked-by-holdrather thanpending.advanceToSoftDeleterequires thependingstate and that the execution time has arrived (too-earlyotherwise), and on success sets the state tosoft-deletedand issues a tombstone (tombstoneIssued: true) to prevent re-creation.advanceToHardDeleterequiressoft-deleted, that the hard-delete deadline has passed, and that no legal hold has since attached (blocked-by-hold). It takes aCryptographicShredOutcomefrom the executor and recordscryptographicShredAppliedverbatim —appliedis true only when a real shred (encryption-key destruction) was carried out and confirmed; a row-level delete without key destruction reportsapplied: falsewithreason: 'deletion-by-row-removal'. The state machine never infers the flag, so it cannot claim a shred that did not happen.cancelDeletionreverses apendingorsoft-deletedrequest.
Soft-delete window and retention constants#
The 30-day reversible window the prose describes is a real, bounded constant.
SOFT_DELETE_DEFAULT_SECONDS = 30 * 86400, and validateSoftDeleteWindow
clamps any tenant override between SOFT_DELETE_MIN_SECONDS = 24 * 3600 (a UX
safeguard against accidental clicks) and SOFT_DELETE_MAX_SECONDS = 90 * 86400
(a regulatory cap on dangling, still-controlled data), returning below-minimum
/ above-maximum errors outside that band.
Per-data-class retention is the RETENTION_DAYS table, surfaced via
retentionDaysFor(dataClass):
| Data class | Retention |
|---|---|
raw-chat |
30 days |
summarized-profile |
durable |
billing |
365 * 7 days (7 years) |
audit |
365 * 7 days (7 years) |
generated-artifact |
per-artifact-policy |
Legal holds (LegalHold) suspend deletion and are evaluated by
holdsApplicable, which matches by userId, honors hold expiry, and treats a
full-account deletion as in-scope for any hold. A hold blocks both the enqueue
and the hard-delete steps, with operator audit recorded on the hold itself.
Customer-Facing Privacy Surface#
The privacy center (§22.5, privacy-surface/privacy-surface.ts) is a single
in-product surface. buildPrivacyCenter returns one PrivacyCenterEntry per
section, each with a summary, a deepLinkPath, and an itemCount. The eight
sections are exactly: consents, memory, exports, deletions, residency,
operator-access-audit, subprocessors, and disclosures — so a user sees
every consent, memory state, export, deletion, residency choice, subprocessor,
disclosure, and an audit of operator access to their data, in one place. The
deep links are concrete (/privacy/consents, /privacy/memory,
/privacy/exports, /privacy/deletions, /privacy/residency,
/privacy/audit, /privacy/subprocessors, /privacy/disclosures).
Two further surface guarantees are enforced in code:
- Plain-language reveal. A
FeaturePrivacyRevealanswers "what this uses, where it goes, how to turn it off" and carries areadingGradeLevel.checkRevealReadingGraderejects copy above the jurisdiction's ceiling — grade 8 for US/CA/UK/EU/BR, defaulting to 10 elsewhere — withreading-grade-too-high. Disclosure copy is also reviewed under Lilith tone policy (Lilith Persona Policy). - Contextual, non-buried prompts.
decideContextualPromptdecides whether to surface a consent prompt at first relevant feature use. It returnsshouldPrompt: falsewith a reason ofconsent-not-required-for-feature,already-granted, orrecently-dismissed(re-showing only afterdismissalReshowSecondselapses), andtrueotherwise — so prompts appear in context, are remembered on dismissal, and are never buried in onboarding.
Admin DSAR and Operator-Access Workflow#
DSAR state machine#
Data-subject access requests (§22.6, dsar/dsar.ts) cover the six DSAR_KINDS:
access, portability, rectification, erasure, restriction, and
objection. A DsarRequest advances through the seven DSAR_STATES:
received → identity-verified → scope-determined → in-execution →
completed, with rejected and restored as terminal alternatives. The
requesting party is recorded as requestingActorKind ∈
{ subject, authorized-representative, operator }.
Eligibility is checked, not assumed. checkEligibility requires identity
verification (identity-not-verified) and validates the request kind against
the jurisdiction: the EU and UK support all six kinds, while the US supports
access / portability / erasure, and CA / BR support access /
portability / rectification / erasure. An unknown jurisdiction returns
jurisdiction-not-supported; an unsupported kind returns
unsupported-kind-in-jurisdiction. Execution is per-data-class:
determineScope records a perClassCompletion map, and recordClassCompletion
marks each scope completed / unavailable and flips the request to
completed only when every scope is terminal, attaching the export's
integrityManifest (bundle id and digest) for portability verification.
Restore is time-windowed and elevated. checkRestoreEligibility returns
tenant-forbids-restore when the tenant policy forbids it and
elevation-required when no elevationApproverActorId is present — a restore
never happens on a single operator's say-so.
Operator access (non-DSAR)#
Operator access to a subject's data outside a DSAR is governed by the same file.
OPERATOR_ACCESS_REASONS enumerates support-investigation,
safety-investigation, privacy-review, research-cohort, and
legal-discovery, and SENSITIVE_OPERATOR_ACCESS flags legal-discovery and
research-cohort as the categories requiring two-operator authorization. Every
OperatorAccessRequest carries a reason, a time-bound window
(timeBoundStartUnixSeconds … timeBoundEndUnixSeconds), the data it is scoped
to (scopedToData), a notificationToSubjectByUnixSeconds deadline (post-hoc
user notification within the compliance window where lawful), and an auditId.
authorizeOperatorAccess rejects an invalid or inverted time window
(time-bound-invalid), a sensitive access lacking a second approver
(two-operator-required), and a sensitive access where the approver is the
requester (self-approval). This is the privacy-side counterpart to the
operator surfaces described in
Tenant, Institution, and Operator Toolkit and
Admin Products — Web and Mobile.
Compliance, Disclosure, and Audit#
Regulatory regimes#
Compliance (§22.7, compliance/compliance.ts) maps regions to regimes rather
than flattening everything into one list. REGULATORY_REGIMES enumerates
GDPR, CCPA, CPRA, LGPD, PIPEDA, US-State-Privacy, FERPA, COPPA,
and UK-DPA-2018, and regimesFor(region) resolves the applicable set:
| Region | Applicable regimes |
|---|---|
EU |
GDPR |
UK |
GDPR, UK-DPA-2018 |
US |
CCPA, CPRA, US-State-Privacy, FERPA, COPPA |
CA |
PIPEDA |
BR |
LGPD |
Notably, the US mapping includes the education- and child-privacy regimes FERPA and COPPA — relevant to institutional Metis — which the prose feature bullet does not name. An unmapped region resolves to the empty set.
Mandatory disclosures#
MANDATORY_DISCLOSURE_KINDS enumerates the five required disclosures:
synthetic-content-disclosure (per Lilith), ai-use-disclosure,
third-party-processor-list, breach-notice, and retention-policy. Each
MandatoryDisclosure carries perLocaleCopy; resolveDisclosure selects the
active (effective, non-archived) disclosure for a region/kind and walks the
BCP-47 locale hierarchy (de-CH → de → first available) to find copy,
returning no-active-disclosure or no-locale-copy when it cannot.
requireDisclosureCoverage returns the missing (region, kind) pairs across
the active launch regions — the compliance dashboard blocks a release while that
list is non-empty. archiveDisclosure supersedes an old disclosure by stamping
archivedAtUnixSeconds; it never deletes, so the historical record stays
reconstructable.
Breach response and audit completeness#
The breach runbook is the seven-stage sequence BREACH_RESPONSE_STAGES:
detect → contain → assess → notify → remediate → postmortem →
disclosure. advanceBreachStage enforces strictly sequential progression
(illegal-transition on any skip or out-of-order timestamp).
regulatoryDeadlineSecondsFromNow computes the notification clock from the
region's strictest regime — 72 hours for GDPR / UK-DPA-2018 / CCPA / CPRA /
LGPD / PIPEDA, 168 hours for US-State-Privacy / FERPA / COPPA, defaulting to 168
hours for an unmapped region.
Audit completeness is measured, not assumed. AUDITABLE_EVENT_KINDS lists the
five event classes that must each be audited: consent-transition,
operator-access, deletion, export, and cross-region-move.
evaluateAuditCompleteness divides audited events by observed events per kind
and returns allCovered: true only when the minimum coverage across all kinds
is ≥ 1 — i.e., every consent transition, operator access, deletion, export, and
cross-region move has an audit event. Immutable storage of those events is owned
by @oshun/audit-platform per the architecture; this module computes the
coverage signal over them.
Where Billing and Crisis Cross This Surface#
Privacy does not live alone in the governance area. Two adjacent mechanisms are worth naming because they cross this surface directly.
Entitlement single-source-of-truth. The billing core
(@oshun/billing-support, libs/oshun/billing-support/) defines six
ENTITLEMENT_CLASSES (free, starter, plus, pro, scholar,
institutional), and billing-aje-bridge.ts collapses them onto the three
canonical OshunEntitlementTier values via TIER_BY_CLASS (free → free;
starter/plus → pro; pro/scholar/institutional → premium).
entitlementTierForSubscription falls back to free whenever a subscription is
not in an entitling state (trial/active/grace/restored), so a lapsed
payment de-entitles immediately. applyPaymentSettlementToSubscription advances
the subscription state machine from a settled Aje payment
(confirmed/failed/refunded), which is how a confirmed crypto payment
becomes an entitlement change. This is the entitlement model that gates
voice/avatar generation and the institutional gradebook; details live on
Support, Entitlements, Billing, and Customer Operations
and
Crypto Payments — Non-Custodial Entitlement Settlement.
Crisis-frame cascade. When a crisis is detected, a single activation must
reach every contemplative surface. The cascade
(libs/oshun/trust-safety/src/crisis/crisis-frame-cascade.ts) publishes
LILITH_CRISIS_FRAME_ACTIVATED_EVENT = 'lilith.crisis_frame.activated' and fans
out to CRISIS_FRAME_SURFACES =
['psyche', 'lilith-video', 'tara', 'iris', 'assistant'], carrying
non-overridable haltSynthesis and suspendMemoryWrites directives. The domain
stays free of @oshun/event-bus (it uses a CrisisFramePublishPort only);
crisis-frame-worker.ts binds the real Redis bus and re-projects each event.
This is the privacy-relevant link because a crisis frame suspends memory
writes — it overrides whatever memory consent would otherwise permit. Full
detail is on Review, Compliance, and Trust & Safety
and Lilith Persona Policy.
Tests and Verification#
The library is covered by Vitest suites under libs/oshun/privacy/src/ (e.g.
privacy.test.ts, deletion-erasers/generated-artifact-eraser.test.ts). The
verification targets match the spec's test list: consent enforcement and the
bundled-sensitive rejection, the withdrawal cascade plan, deletion completeness
with tombstones and the verbatim shred outcome, restore authorization, residency
routing and cross-region propagation completion, audit completeness, DSAR
end-to-end across the seven states, the export integrity manifest round-trip,
and the breach runbook stage ordering. The generated-artifact-eraser test
exercises the real Isis deleteMany path against the live local DB (two of
three owners' rows deleted, the third left intact), confirming that the one
place the privacy engine touches domain persistence does so correctly.
Cross-domain DSAR fan-out and live audit-platform persistence are the runtime's
responsibility and are integration concerns beyond this pure core.
Related#
- Iris Memory and Identity — the memory consent surface and recall, jointly owned with the consent core here.
- Review, Compliance, and Trust & Safety — policy taxonomy, severity SLAs, decisions, and the crisis-frame cascade.
- Support, Entitlements, Billing, and Customer Operations — entitlement classes, dunning, metered billing, and support routing.
- Crypto Payments — Non-Custodial Entitlement Settlement — how a settled Aje payment advances the subscription and entitlement.
- Lilith Persona Policy — disclosure tone policy and synthetic-content disclosure.
- Tenant, Institution, and Operator Toolkit — operator access and institutional data-sharing context.
- Architecture, Platform Foundations, and Security — where residency, audit storage, and the event bus sit in the platform.
- Companion docs: Security, Privacy, and Compliance and Trust & Safety and Privacy.
- Backlog: §22; feature hub: ../features.md.