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-expandedflips on open - Dialog —
role="dialog" aria-modal="true",aria-labelledbyto 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
valuemust remain canonical - Rationale textarea — min 10, max 500 chars (
TRUST_SAFETY_FLAG_*constants); live counter visible - Submit —
submitTrustSafetyFlagActionserver 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-accessHttpOnly cookie vianext/headersand forwards asAuthorization: Bearer … - BFF endpoint —
POST /v1/user-reports(seeapps/oshun/bff/src/routes/user-reports.ts), gated byabuseProtection+authProtectionpre-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, prioritymedium, outcomenone, reviewer unassigned — only moderators mutate - Customer queue prepended via
recordCustomerUserReport; surfaces in the admin Trust & Safety workspace at/trust-safety - Saved view —
/trust-safetyis one of the canonical admin saved views (seeapps/oshun/bff/src/admin/admin-search.ts:303for 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 openreentry-flow.ts—buildReentryFlow,showReentryPrompt,acceptReentry,declineReentry; routes to conservative re-entry home (contemplative-strict tone band) or idle-safe surfacecheck-ins.ts—scheduleCheckIns(24 h + 7 d),applyCheckInEventfor deliver/dismiss/opt-outreframe-protection.ts— gates reframe attempts that would minimize the crisislocales.ts— locale parity for crisis-resource hand-offsincident-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. 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 setsrobots: index=false, follow=false; mount is<ProfileSafetyJourney />which reads/writesoshun-profile-safety-v1inlocalStorage(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 likeus-988,us-griefshare, queue lane) - Cross-tab sync —
storageevent 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 — auth gates the flag submission
- 08-telemetry-analytics.md — event taxonomy
- Per-view:
customer/09-account/profile-safety.md - Feature spec:
V1/features.md
Open questions / known gaps#
-
ProfileSafetyJourneypersists tolocalStorageonly — 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/safetymatches the customer view 1:1 (the "audit-platform-visible" framing onprofile-safety.mdis still ambiguous) - Document each domain's per-target id contract before adding the flag button to Arete, Nyx, Metis surfaces
-
trust_safety_appealticket 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