V1 Web PWA · Surface walkthrough

Shell: Trust and safety

A per-surface walkthrough of the V1 Web PWA: layout, states, interactions, data, and cross-references.

unspecified
9sections5 minread

On this page

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 playerTaraSitPlayer.tsx:320, targetKind="content" on the active sit
  • Veritas article actionsVeritasArticleActions.tsx:115, targetKind="content" on a story
  • Veritas claim detailVeritasClaimDetailWorkspace.tsx, flag a claim/source
  • Nisaba passage actionsNisabaPassageActions.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:

  • Triggeraria-haspopup="dialog", aria-expanded flips on open
  • Dialogrole="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
  • SubmitsubmitTrustSafetyFlagAction 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 endpointPOST /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.tsbuildReentryFlow, showReentryPrompt, acceptReentry, declineReentry; routes to conservative re-entry home (contemplative-strict tone band) or idle-safe surface
  • check-ins.tsscheduleCheckIns (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.tsbuildIncidentRecord + 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. 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

  • 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 syncstorage 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#

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