# Shell: Trust and safety

Source: `apps/oshun/web/src/components/TrustSafetyFlagButton.tsx`,
`apps/oshun/web/src/app/_actions/submit-trust-safety-flag.ts`,
`apps/oshun/web/src/components/lilith/TaraSitPlayer.tsx`,
`apps/oshun/web/src/components/lilith/VeritasArticleActions.tsx`,
`apps/oshun/web/src/components/lilith/NisabaPassageActions.tsx`,
`apps/oshun/web/src/components/domains/veritas/VeritasClaimDetailWorkspace.tsx`,
`apps/oshun/web/src/app/profile/safety/ProfileSafetyJourney.tsx`,
`apps/oshun/bff/src/routes/user-reports.ts`,
`libs/oshun/persona-policy-lilith/src/crisis-recovery/`

Trust & Safety lives in two shell-level affordances that any view can opt into:
the **customer flag button** (report a user or piece of content) and the
**crisis-recovery substrate** (stillness window, re-entry flow, soft check-ins,
incident record). Both feed the same operator surfaces. Walk this when the flag
button moves, when a domain adopts it, or when the Lilith recovery contract
changes.

## Flag affordance — `TrustSafetyFlagButton`

The button is small, unobtrusive, and mounts on the same row as a primary
content action. Today the canonical surfaces are:

- [ ] **Tara sit player** — `TaraSitPlayer.tsx:320`, `targetKind="content"` on
      the active sit
- [ ] **Veritas article actions** — `VeritasArticleActions.tsx:115`,
      `targetKind="content"` on a story
- [ ] **Veritas claim detail** — `VeritasClaimDetailWorkspace.tsx`, flag a
      claim/source
- [ ] **Nisaba passage actions** — `NisabaPassageActions.tsx:109`, flag a
      passage
- [ ] **No other domain mounts it in V1** — Arete, Nyx, and Metis viewers stay
      off until the per-target ID contract is firmed up

When other surfaces adopt the button, they pass `targetKind` (`user` or
`content`), `targetId` (≤ 200 chars), and a human-readable `targetLabel` (≤ 200
chars). The 10 categories (`harassment`, `hate`, `impersonation`,
`misinformation`, `spam`, `nsfw`, `violence`, `self_harm`, `privacy`, `other`)
mirror `ADMIN_REPORT_CATEGORIES` in
`libs/contracts/src/common/admin-moderation.ts` — the server re-validates so the
client labels can localize without weakening the schema.

## Flag dialog

`TrustSafetyFlagButton` opens an inline modal dialog. Walk:

- [ ] **Trigger** — `aria-haspopup="dialog"`, `aria-expanded` flips on open
- [ ] **Dialog** — `role="dialog" aria-modal="true"`, `aria-labelledby` to title
- [ ] **Outside click** — closes via the backdrop click handler
- [ ] **Escape** — closes (browser default for modal dialogs in the focus trap;
      confirm if a custom handler is added)
- [ ] **Category select** — 10 options; labels can drift for localization, the
      `value` must remain canonical
- [ ] **Rationale textarea** — min 10, max 500 chars (`TRUST_SAFETY_FLAG_*`
      constants); live counter visible
- [ ] **Submit** — `submitTrustSafetyFlagAction` server action, not a direct
      fetch
- [ ] **Success state** — green banner "Thanks — the Trust & Safety team will
      review this flag." then dismiss button
- [ ] **Error: invalid_payload** — inline `role="alert"`, no submission
- [ ] **Error: unauthenticated** — "Sign in to submit a Trust & Safety flag."
- [ ] **Error: rate_limited** — "Too many reports — wait a minute and try
      again." (HTTP 429 from BFF)
- [ ] **Error: network_error** — "Flag could not be sent — please try again."

## Flag pipeline (client → BFF → admin queue)

- [ ] **Server action** runs in Next; reads `oshun-access` HttpOnly cookie via
      `next/headers` and forwards as `Authorization: Bearer …`
- [ ] **BFF endpoint** — `POST /v1/user-reports` (see
      `apps/oshun/bff/src/routes/user-reports.ts`), gated by `abuseProtection` +
      `authProtection` pre-handlers
- [ ] **Server-side validation** re-runs the same zod schema (targetKind,
      category, rationale length) — client cannot bypass
- [ ] **Reporter identity** — pulled from auth context (`authContext.userId`),
      not the request body; client cannot spoof another user
- [ ] **Reporter display name** — derived from last four chars of userId until
      profile lookup wires in
- [ ] **Status** defaults to `open`, priority `medium`, outcome `none`, reviewer
      unassigned — only moderators mutate
- [ ] **Customer queue** prepended via `recordCustomerUserReport`; surfaces in
      the admin Trust & Safety workspace at `/trust-safety`
- [ ] **Saved view** — `/trust-safety` is one of the canonical admin saved views
      (see `apps/oshun/bff/src/admin/admin-search.ts:303` for the deep link
      contract)

## Lilith crisis-recovery substrate

`libs/oshun/persona-policy-lilith/src/crisis-recovery/` (barrel:
`crisis-recovery/index.ts`) — the safety-side flows that fire when the Lilith
policy router detects a crisis disclosure (acute grief, suicide ideation, active
self-harm, panic, dissociation, trauma resurfacing, substance crisis,
interpersonal violence, abuse disclosure, eating disorder, child safety,
psychosis-adjacent, violence toward others). Modules:

- `stillness-window.ts` — opens a minimum-duration "stillness window"; suspends
  non-safety surfaces (`teaching`, `invitation`, `scheduled-generation`,
  `companion-suggestion`, `studio`, `gallery`, `voice-clone`, `live-scene`)
  while leaving safety surfaces open
- `reentry-flow.ts` — `buildReentryFlow`, `showReentryPrompt`, `acceptReentry`,
  `declineReentry`; routes to conservative re-entry home (contemplative-strict
  tone band) or idle-safe surface
- `check-ins.ts` — `scheduleCheckIns` (24 h + 7 d), `applyCheckInEvent` for
  deliver/dismiss/opt-out
- `reframe-protection.ts` — gates reframe attempts that would minimize the
  crisis
- `locales.ts` — locale parity for crisis-resource hand-offs
- `incident-record.ts` — `buildIncidentRecord` + `buildWhatHappenedView`;
  produces the audit-platform-visible record consumed by `/profile/safety`

This is the **interception overlay** that runs out-of-band from the auth/role
gates in [`04-auth-session.md`](./04-auth-session.md). A crisis frame can fire
for any signed-in user inside any domain; the substrate decides what surfaces
freeze and what the next interaction looks like, independent of subscription
tier or role.

## `/profile/safety` audit log

Per-view:
[`customer/09-account/profile-safety.md`](../customer/09-account/profile-safety.md)

- [ ] **Route** `/profile/safety`; metadata sets
      `robots: index=false,     follow=false`; mount is
      `<ProfileSafetyJourney />` which reads/writes `oshun-profile-safety-v1` in
      `localStorage` (V1 substrate; BFF persistence pending)
- [ ] **Five cards** — "What happened" (crisis type, open/close times, SLA,
      redacted excerpt), "Stillness window" (per-surface suspension via
      `isSurfaceSuspended`), "Re-entry" (show prompt → decline / accept), "Soft
      check-ins" (24 h + 7 d deliver / dismiss / opt-out), "Resources surfaced"
      (resource IDs like `us-988`, `us-griefshare`, queue lane)
- [ ] **Cross-tab sync** — `storage` event listener re-hydrates state when
      another tab mutates the localStorage key

## Crisis-frame visibility (read-only surfaces)

When a crisis frame is active, every surface that reads from Lilith respects the
stillness suspension. Verify in dev that **teaching** (Nisaba / Metis nudges),
**scheduled invitations** (Tara / Arete reminders), **scheduled generation**,
**studio** entry, **companion suggestions**, **gallery**, **voice-clone**, and
**live-scene** all hold or gate with a calm-down notice until stillness closes.

## Telemetry

- [ ] `trust_safety_flag_submitted` — category, targetKind, success/failure
      reason
- [ ] `crisis_frame_opened` — type, severity, locale
- [ ] `stillness_window_opened / closed` — duration, configured minimum
- [ ] `reentry_prompt_shown / declined / accepted` — routed-to surface
- [ ] `check_in_delivered / dismissed / opted_out` — kind (24h / 7d)
- [ ] **No PII in payloads** — never the rationale text, never the reporter's
      display name, never another user's id beyond the canonical target id

## Cross-references

- [04-auth-session.md](./04-auth-session.md) — auth gates the flag submission
- [08-telemetry-analytics.md](./08-telemetry-analytics.md) — event taxonomy
- Per-view:
  [`customer/09-account/profile-safety.md`](../customer/09-account/profile-safety.md)
- Feature spec: [`V1/features.md`](../../V1/features.md#lilith-persona-policy)

## Open questions / known gaps

- [ ] `ProfileSafetyJourney` persists to `localStorage` only — BFF persistence
      endpoint (read + acknowledge) not yet shipped
- [ ] Reporter display-name BFF substitution still pending; production should
      not leak the last four chars of the userId once profile lookup is wired
- [ ] Confirm whether the operator audit view of `/profile/safety` matches the
      customer view 1:1 (the "audit-platform-visible" framing on
      `profile-safety.md` is still ambiguous)
- [ ] Document each domain's per-target id contract before adding the flag
      button to Arete, Nyx, Metis surfaces
- [ ] `trust_safety_appeal` ticket category lifecycle (open → reviewed → upheld
      / overturned) is referenced by admin saved views but the customer-side
      appeal entry point is not in the V1 surface map yet
