Disciplines · Audits

Residual Audit — Governance, Safety, Support, and Privacy (features.md 5204–5760)

Gap audits and as-built reviews.

1section22 minread

On this page

Auditor slice: Trust & Safety (moderation/appeals/crisis/abuse-patterns/operator surfaces/evals), Support/Entitlements/Billing/Customer-Ops, Crypto Payments, Privacy/Consent/DSAR/Retention/Residency. Date: 2026-06-11. Evidence rule: current code only; every delegation chain followed to source.

Recorded non-findings honored: A1/A-phase auth gates, B1 DSAR export bundle, C10 settlement→entitlement leg, E3 reminder consent/quiet-hours, the Ed25519 deletion fan-out, the 30-day grace-expiry deletion worker (data-deletion/deletion-worker.ts + state.ts:68 — real), takedown + counter-notice + repeat-infringer wiring, chargeback screening, crisis-frame cascade + payment-path crisis suppression, disclosure-gate enforcement on tier-B/C quotes (quote-builder.ts:92-93), and everything that is genuinely deploy-bound (chain watcher, invoice-target provisioner, self-hosted nodes, payment-processor creds).


1. GET /v1/payments/invoices serves every customer's invoice ledger to anonymous callers#

Severity: P0-SEC

Evidence: apps/oshun/bff/src/routes/domain-stubs.ts:2349-2364 — the route is registered with no preHandler at all (no auth, no origin/CSRF guard, unlike the quote route at :2393 which carries [originGuard, csrfGuard]); there is no global auth hook (app.ts:707 registers only an onResponse metrics hook). It returns listCryptoInvoices(...) verbatim — apps/oshun/bff/src/payments/invoice-store.ts:219-239 returns full CryptoInvoiceRecords including purchaserUserId (:71), entitlementPlan (:73), receivingTargetSummary (the payment address/summary, :64), fiatAmountMinor, chainAmount, txId, and status. The tenantId query param is an optional filter, not a scope. The by-id route (:2369) is also unauthenticated but the id is a crypto.randomUUID capability (:2453) — the list endpoint destroys that property by enumerating every id.

Spec promise: features.md 5485–5497 — per-invoice Monero subaddresses so "two paying customers can never link each other's payments"; telemetry "stores only the hash of the subaddress, never the address itself"; 5374–5378 billing history is an account surface. Also the store's own header comment (invoice-store.ts:6) calls this "the customer's" list.

What the code actually does: any anonymous HTTP client can dump the entire durable invoice ledger — every user's plan purchases, user ids, amounts, receiving targets, and settlement txids. This simultaneously breaks cross-customer payment unlinkability (the exact privacy property the crypto section exists for) and discloses billing PII.

Fix sketch: require a session; scope the list to authContext.userId === purchaserUserId (plus an admin-scoped operator variant). Keep the by-id route as an unauthenticated capability URL if the paywall needs it pre-login, but strip purchaserUserId from that DTO.


2. @oshun/billing-support is still fully orphaned — the entire customer-ops machinery has zero runtime consumers#

Severity: P0-STRUCT

Evidence: grep -rn "@oshun/billing-support" apps/ libs/ tools/zero hits outside the library itself. The library is real, domain-specific engine code: libs/oshun/billing-support/src/dunning/dunning.ts:83-176 (payment-failure → dunning-stage state machine, grace windows, lapse degradation, trial-prompt schedule), support-cases/support-cases.ts:88-258 (case routing queues, per-queue SLA budgets, SLA evaluation, CSAT capture + aggregation, grounded copilot answers), self-serve/self-serve.ts:45-203 (refund eligibility, pause/resume, promo codes), entitlements/entitlements.ts:77-348 (entitlement classes, feature gates, subscription transitions, upgrade proration, intro offers), metered/metered.ts:75-174 (usage accrual, budget caps, meter cards), tax-billing/tax-billing.ts:68-158 (VAT/GST application, locale currency formatting, receipt summaries). None of it is reachable from any route, worker, or app.

Spec promise: features.md 5361–5407 — entitlement classes, trials, upgrade/downgrade, dunning sequences, grace windows, graceful degradation, self-serve refund/credit/pause/promo, tax/VAT-aware invoicing, metered billing with alerts and budget caps, support-case intake/routing/SLAs/CSAT.

What the code actually does: the runtime has exactly two entitlement mechanisms — the persisted free|pro|premium plan read by middleware/entitlements.ts:28-39 and the generation-tier resolver — and nothing else from this feature family. The engines exist; the product does not.

Fix sketch: wire the highest-leverage seams first: (a) dunning + lapse degradation around the plan field (see finding 3), (b) a support-case intake/queue route pair over routeCase/slaForCase/evaluateCaseSla (see finding 4), (c) featureGate/featuresFor as the entitlement middleware's feature vocabulary.


3. No subscription at runtime: crypto settlement grants a perpetual plan — no renewal invoices, no renewal reminders, no grace window, no dunning#

Severity: P0-STRUCT

Evidence: apps/oshun/bff/src/payments/settlement-route.ts:85-93 flips customerAuthStateStore.updatePlan(...) — the plan field (auth/customer-auth-store.ts:184,229) carries no expiry, period, or renewal date. Nothing anywhere creates a renewal invoice (issueCryptoInvoice is called only from the quote route), the renewal-reminder engine libs/oshun/payments-bridge/src/customer-surface/reminder-cadence.ts has zero app consumers (symbol grep: NONE), and the dunning machinery is orphaned (finding 2). The reminders dispatcher (E3) carries no payment-renewal producer (apps/oshun/bff/src/reminders/ — session/assignment/content-drop/streak producers only).

Spec promise: features.md 5572–5575 — "subscription renewals create a new invoice at renewal time… notification 7 days, 24 hours, and 1 hour before renewal; failure to settle by expiry triggers the same grace-window degradation"; 5381–5386 dunning sequences and graceful degradation on lapse.

What the code actually does: one settled invoice = lifetime entitlement. There is no concept of a billing period, so renewal, lapse, degradation, and dunning can never trigger. This is the largest behavioral gap between the payment spec and the in-repo half (it requires no chain watcher — it's pure period bookkeeping + invoice issuance + the existing reminder dispatcher).

Fix sketch: add planPeriodEndsAtUnixSeconds to the auth record (stamped at settlement); a renewal worker (mirror the deletion/reminder worker pattern) that issues a renewal invoice via the quote builder, feeds reminder-cadence offsets into the messaging dispatcher, and on unpaid expiry applies degradedEntitlementOnLapse() through recordPaymentFailure/dunning stages.


4. Support-case intake does not exist; the operator support workspace triages fabricated cases#

Severity: P0-STRUCT

Evidence: no /v1/support* route exists anywhere in apps/oshun/bff/src (route grep across all 487 route files). Customer-side, the only intakes are POST /v1/feedback (B14 bug-report intake) and POST /v1/user-reports (safety reports, routes/user-reports.ts:107). The admin app's support console (apps/oshun/admin/src/app/support/page.tsx) renders the seeded per-operator workspace — admin/state.ts ships fixture refund tickets (AdminRefundTicketRecord, :1153) and support cases cloned per operator from ADMIN_USER_SEED (:18971). The real case engine (billing-support/support-cases.ts — routing, SLA, CSAT) is orphaned (finding 2).

Spec promise: features.md 5397–5404 — support case intake, routing, member timeline, refund/credit, escalation, first-response/resolution SLAs, CSAT capture, omnichannel history.

What the code actually does: a member who needs help is pointed at mailto:billing@oshun.app (apps/oshun/web/src/app/billing/page.tsx:103-110); an operator who opens Support sees fabricated tickets. No real case can enter, move through, or exit the system.

Fix sketch: POST /v1/support/cases (session-stamped) + durable store + routeCase/slaForCase for queue/SLA assignment + an admin-scoped queue read/decide pair; surface intake from the existing help/feedback affordances.


5. The @oshun/trust-safety enforcement engines are orphaned: severity SLAs, repeat-offender ladder, coordinated-abuse detection, safety dashboard, eval gates#

Severity: P0-STRUCT

Evidence: runtime imports of @oshun/trust-safety are crisis-only (apps/oshun/bff/src/safety/crisis-frame-runtime.ts:20-29; mobile companionAppModel.ts uses classifySeverity/slaFor in a descriptive model). Zero app consumers (non-test grep: NONE) for: applyOffense (warn→restrict→ suspend→ban ladder, abuse-patterns.ts:37), detectCoordinationClusters (:101), classifyBotBehavior (:172), detectClassifierDrift (:201), classifyTenantAbuseRates (:254), buildAppealEvidencePack (operator-surfaces.ts:40), buildRepeatOffenderTimeline (:72), buildSafetyDashboard (:133), trend builders (:287-305), evaluateSla (severity.ts:184), and the whole evaluation module (release-gate verdicts, calibration error — evaluation.ts).

Spec promise: features.md 5258–5276 (severity classes with triage/action SLAs and escalation rules), 5314–5329 (repeat-offender rules, coordinated-abuse detection, bot signals, drift detection, tenant aggregates), 5331–5343 (appeal evidence pack, repeat-offender summary, safety dashboard, trend dashboards), 5345–5359 (eval suites, regression blocking).

What the code actually does: moderation decisions happen with no severity classification, no SLA clock, no offense counter, no escalation, and operators have no safety dashboard. The engines compute all of this correctly — nothing feeds them or reads them.

Fix sketch: stamp classifySeverity + slaFor onto every moderation-queue/abuse-report item at enqueue; run applyOffense on each upheld decision and persist the per-user counter; an admin GET /v1/admin/safety/dashboard over buildSafetyDashboard fed from the real moderation store + abuse-report store.


6. The Trust & Safety operator inbox interleaves REAL customer reports with unlabeled fabricated cases (including fake CSAM/NCMEC records)#

Severity: P0-HONESTY

Evidence: apps/oshun/bff/src/admin/state.ts:13848-13851 — "…prepends a clone of this queue to the per-admin userReports array so customer-submitted flags appear ahead of the pre-seeded fixture rows" (the code's own words). Real reports enter via POST /v1/user-reports (routes/user-reports.ts:91,107recordCustomerUserReport, state.ts:13867). The fixture rows they're mixed with include fabricated critical cases: state.ts:8317-8336 an image-moderation case with signals: [{ kind: 'csam', confidence: 0.98 }], matchedHashset: 'ncmec-csam-v3', named fake reviewers; plus fabricated avatar-deception, refund-ticket, DSAR, incident, and compliance records — all cloned per operator from ADMIN_USER_SEED (:18971) with no fixture/seed marker on any served record.

Spec promise: features.md 5258–5263 (P0 = CSAM, triage < 5 min, parallel safety-incident record — i.e., these queues are operationally load-bearing); 5331–5343 (operator surfaces must show real account/incident context).

What the code actually does: the per-operator-clone workspace model is a recorded baseline (ground-truth audit C13 ships against "the same per-operator snapshot"), so the architecture itself is not re-litigated here. The honesty defect is narrower and new relative to that record: live customer reports and fabricated cases are indistinguishable in the same array, and a fabricated "NCMEC hash match" case sits in a real operator's queue. An operator can spend P0-SLA attention on a fake CSAM case, or — worse — learn to dismiss CSAM-flagged rows as "the demo data".

Fix sketch: stamp origin: 'seed' | 'live' on every workspace record at the DTO boundary and render seeds with an explicit register (the D1 DomainPreviewBanner precedent); route real userReports to a shared durable store rather than a prepend into per-operator clones.


7. Moderation bans are written but never enforced anywhere#

Severity: P1

Evidence: POST /v1/admin/moderation/bans issues a real @aja/content-moderation BanRecord (routes/admin-moderation.ts:240-280moderation-store.ts:issueBan), and banStatus is read only by the admin GET (admin-moderation.ts:330). Repo-wide grep for banStatus/isBanned/activeBan outside the moderation files: zero user-facing call sites. No auth middleware, message route, persona route, generation route, or upload path consults the ban store.

Spec promise: features.md 5314–5318 — escalating consequences "warn → restrict feature → suspend → ban"; a ban is a consequence, not a record.

What the code actually does: a "permanently banned" user retains full product access; the ban exists only as an admin-readable row. Stored-config-never-enforced.

Fix sketch: consult moderationStore.banStatus(userId) in createAuthPreHandler (or a dedicated pre-handler on mutating customer routes); map feature_specific/content_specific scopes to the relevant route families; surface 403 account_restricted with the appeal affordance.


8. Appeals: no reviewer rotation, no two-reviewer signoff, and the actor identity is client-supplied#

Severity: P1

Evidence: libs/aja/content-moderation/src/moderation-workflow.ts:449-485resolveAppeal accepts any reviewerId, never compares it to the original decision's moderator; startReview(appealId, _reviewerId) (:438) ignores the reviewer entirely. No two-reviewer path exists for any decision class. Worse, the route takes moderatorId/reviewerId/issuedBy from the request body (routes/admin-moderation.ts:145,219,256) rather than request.authContext.userId — an authenticated operator can stamp decisions, appeal resolutions, and bans as any other operator.

Spec promise: features.md 5285–5295 — appeals triaged by "a different reviewer than the original decision", mandatory cooling-off, two-reviewer signoff for P0/permanent-ban/public-figure/cloned-voice decisions.

What the code actually does: the same operator can decide, "appeal" to themselves, and resolve — and can do all three under a colleague's name. The audit trail's actor field is unverified input.

Fix sketch: derive actor ids from authContext (drop them from the body); in resolveAppeal, reject reviewerId === decisionHistory.last().moderatorId; add a requiresSecondReviewer flag derived from severity/ban-type with a two-step approve.


Severity: P1

Evidence: the real V1-PRIV-006 ledger is complete and auth-gated — /v1/consent + per-flow grant/withdraw/revoke/renew/audit (routes/consent.ts:295-346 over consent/state.ts, 999 lines, real taxonomy, append-only history). Zero web/mobile consumers (repo grep for v1/consent outside the BFF: only Lilith-domain files and an e2e spec). The customer privacy page /profile/data instead reads GET /v1/data-rights/consent — a guardedFixtureRoute serving three fabricated consents (routes/domain-stubs.ts:1867-1901; 503 in production), so in prod the page's consent panel renders empty (page.tsx:69,73 consent?.consents ?? []) and it has no grant/withdraw controls at all. Meanwhile the ledger IS enforced: persona voice/avatar modality requires a granted voice/avatar flow (routes/personas-consumer.ts:42-47) — which no surface can grant, so premium voice personas are a permanent dead-end for every member.

Spec promise: features.md 5654–5670 (per-feature opt-in, withdrawal at any time), 5714–5726 (privacy center "listing every consent…"; consent prompts "at first relevant feature use").

What the code actually does: a correct, audited consent engine with no door. Two consent sources of truth (/v1/consent real vs /v1/data-rights/consent fixture), and a consent-gated feature whose grant path doesn't exist.

Fix sketch: re-point /profile/data at GET /v1/consent and add grant/withdraw toggles posting to the real per-flow verbs; mount a first-use consent prompt on the persona voice/avatar picker; delete the data-rights/consent fixture route.


10. Crisis residuals: resources are not region-aware, and the post-crisis machinery (check-in, cooldown, mandatory reporting) is orphaned#

Severity: P1

Evidence: GET /v1/safety/crisis-resources hardcodes region: 'global' (routes/safety.ts:63) and serves the @iris/emotional-wellbeing curated catalog — real 988 / Crisis Text Line 741741 data (safety/wellbeing-resource-service.ts:17,103), i.e., US numbers for every caller in every country. The region-and-language-aware selector exists in libs/oshun/trust-safety/src/crisis/crisis.ts:71-100 (routeCrisis filters catalog by detection.region + language) and is unconsumed, as are planCheckIn (:146, post-crisis opt-in check-in), isInCooldown (:173, no-recommendation cooldown), selectLegalContact (:197) and createMandatoryReport (:208, jurisdiction-aware reporting). The wired part — frame activation, cascade, payment suppression — is real and recorded.

Spec promise: features.md 5300–5312 — "region-aware crisis resources…, language-matched"; post-crisis opt-in check-in, no-recommendation cooldown, jurisdiction-aware mandatory reporting with per-region legal contact.

What the code actually does: a UK user in crisis is shown a US hotline; a resolved crisis frame triggers no check-in plan, no recommendation cooldown, and no reporting-obligation evaluation.

Fix sketch: pass the caller's locale/region (Accept-Language + profile region) into routeCrisis over a region-keyed catalog; on crisis-frame resolution, persist planCheckIn output into the reminders dispatcher and consult isInCooldown in the recommendations route.


11. Signed payment receipts are never produced — the configured ReceiptSigner has no call site#

Severity: P1

Evidence: payments-composition.ts:78-94 resolves the Ed25519 ReceiptSigner from env and binds it into PaymentsRuntime ("used to sign the SETTLEMENT receipt", :8-9) — but settlement-route.ts never imports the runtime or the signer; it confirms the invoice and returns a plain JSON ack (:106-115). Repo grep: no call to any receipt-signer signing function from any app. There is also no receipt read endpoint, so even the unsigned settlement facts (txid, confirmedAt) reach the customer only via the leaking invoice list (finding 1).

Spec promise: features.md 5564–5568 — receipts "signed by the Oshun audit-platform signing key so a customer holding only the receipt and the chain can verify payment without trusting Oshun's API", provenance-bundled with txid/block data; 5374–5378 billing history with receipts.

What the code actually does: even a fully-configured deploy (signer key + provisioner + watcher) settles invoices without ever emitting the cryptographically verifiable receipt the spec centers on.

Fix sketch: in the settlement route, build the receipt payload (invoice fields + txid + confirmedAt), sign with getBoundPaymentsRuntime().signer, persist it on the invoice record, and serve it on an authenticated GET /v1/payments/invoices/:id/receipt.


Severity: P1

Evidence: the consent store models an analytics flow (soft opt-in, consent/state.ts:47,598) with a withdraw verb — but the web analytics transport posts unconditionally (apps/oshun/web/src/analytics/transport.ts:30-37, no consent/DNT check), and the BFF ingest accepts session-less batches with no consent consultation (analytics/events-ingest.ts:86-125; its only privacy property is the 7-day / 5000-event prune). No code path reads the analytics consent flow (consent-store consumers: consent routes, export bundle, residency middleware, personas-consumer — nothing analytics-side).

Spec promise: features.md 5639–5641 (analytics-granularity consent), 5663–5666 ("withdrawal is effective immediately on storage and recall"), 5749–5752 (every consent transition enforced + audited).

What the code actually does: a member who withdraws analytics consent in the ledger (could they reach it — see finding 9) keeps emitting and storing events identically.

Fix sketch: gate the shared sink on the member's analytics consent (hydrate once per session from /v1/consent/analytics, default-on per spec); server-side, drop events whose bearer session resolves to a withdrawn flow.


13. The BFF invoice state machine diverges from the spec contract, and the rail-agnostic entitlement bus is bypassed#

Severity: P1

Evidence: invoice-store.ts:23'pending' | 'confirmed' | 'expired' | 'cancelled'. The spec's created → seen → confirmed → settled → entitlement_granted with overpaid/underpaid/refunded terminals lives in the bridge's state-mapper.ts:52-69 (payment.invoice.underpaid, refunded, amount-tier confirmation policies) and the cross-rail event emitter in entitlement-bus/emitter.tsboth unconsumed by the BFF (grep: EntitlementEmitter/emitEntitlement → NONE; the settlement route calls customerAuthStateStore.updatePlan directly, settlement-route.ts:85-93). There is no underpayment-tolerance auto-credit or overpayment refund-or-credit surface, and a settlement webhook carries no amount at all (:41 — body is {invoiceId, txId, confirmedAtUnixSeconds}).

Spec promise: features.md 5554–5558 (lifecycle incl. underpaid/overpaid handling), 5569–5571 ("confirmation events publish to libs/shared/event-bus with the same topic schema used by the fiat adapter, so the entitlement service is payment-rail-agnostic").

What the code actually does: a second, thinner state machine; partial payments are indistinguishable from full ones at the webhook contract level; entitlement granting is hard-wired to the crypto route rather than bus-mediated, so a future fiat rail cannot share the machinery the spec requires.

Fix sketch: extend the webhook body with paidAmountAtomic; run it through state-mapper to classify settled/underpaid/overpaid; publish the resulting V1PaymentEvent on the shared event bus and move the plan flip into a bus consumer.


14. No refund path exists in-repo: refund intake, refund-initiation, and the cold-spend queue are all orphaned#

Severity: P1

Evidence: no customer or admin refund route exists (BFF grep for "refund" outside fixtures: only creator-analytics fixture numbers and admin/state.ts seeded refund tickets). The purpose-built modules are unconsumed: payments-bridge/src/admin-surface/refund-initiation.ts (grep initiateRefund → only a Lilith fiat-ramp app), the entire cold-spend-queue/ (queue, sweep-policy, hw-signing fixture, audit attestation — ColdSpendQueue/enqueueUnsignedTransaction → NONE), and admin-surface/invoice-timeline.ts + node-health-panel.ts (NONE). Refund eligibility logic is also orphaned (finding 2, self-serve.ts:45 evaluateRefundEligibility).

Spec promise: features.md 5576–5581 — customer-initiated refunds with explicit destination address (Monero) / originating-address default (BTC/EVM), queued unsigned transactions, operator hardware-wallet co-sign, audit-logged broadcast; 5384–5388 customer self-serve refund.

What the code actually does: only the final signing/broadcast is deploy-bound; the locally-buildable half — refund request intake, destination capture, eligibility, the unsigned-tx queue, the operator initiation console — exists as library code with no runtime. A customer's refund today is a mailto link.

Fix sketch: POST /v1/payments/invoices/:id/refund-request (session-scoped, destination-address capture per asset rules) → evaluateRefundEligibility → enqueue via the real ColdSpendQueue → an admin-scoped initiation/list route over refund-initiation.ts; broadcast stays deploy-bound.


15. Moderation queue, appeals, and bans are memory-only — every governance decision evaporates on restart#

Severity: P1

Evidence: moderation-store.ts:105-108ModerationStore holds createModerationQueue()/createAppealManager()/createBanManager() in plain class fields; no wireDurable*/snapshot sink exists (contrast invoice-store.ts:117-124, the feedback store, reminder store — the codebase's established durable-snapshot convention). The takedown store is the same pattern (takedown-store.ts:17-30, in-memory providers; its own header says so).

Spec promise: features.md 5210–5214 (immutable audit events, retention enforcement, chain-of-custody); 5285–5292 (appeal lifecycle with audit) — moderation decisions and bans are compliance records, not ephemera.

What the code actually does: a deploy or crash deletes the moderation queue, all decision history, all appeals, and all active bans (which were unenforced anyway — finding 7).

Fix sketch: wrap both stores with the existing createSnapshotSink(DurableSnapshotStore, key) write-through, serializing the queue/appeal/ban maps; hydrate at boot like the invoice ledger.


16. The per-artifact residency guard (V1-PRIV-018) has zero callers#

Severity: P2

Evidence: middleware/residency-guard.ts wraps ResidencyEnforcementService with audit emission and 403 translation; repo grep for enforceResidency outside the guard + its spec: none. What is enforced is transport-level routing: the onRequest context middleware (residency-routing.ts:32, registered at app.ts:589) plus fail-loud zone routing in adapters/domain-service-adapters.ts:2593-2629 (503 when a plane can't satisfy the zone) — real, and credited. But no BFF route ever performs the artifact-type read/write residency check or emits its audit events.

Spec promise: features.md 5676–5690 — residency honored per data artifact with audit; cross-region exceptions logged.

Fix sketch: call enforceResidency(request, reply, { artifactType }) from the customer-data route families that the guard's own header lists (memory, exports, deletion), passing the deployment zone.


17. Per-data-class retention schedules exist as an orphaned table — nothing enforces them#

Severity: P2

Evidence: libs/oshun/privacy/src/export-deletion/deletion.ts:38 retentionDaysFor (raw chat 30d, billing 7y, durable profile, per-artifact policy) — zero app consumers. Conversation history is bounded by a FIFO turn cap, not time (conversation/conversation-history-store.ts:12,36). The domain-specific retention consoles (isis retention-policy-store.ts:128 operator-triggered scan, aja/studio stores) are real but cover their domains only; no worker applies the platform data-class schedule to chat, support, or billing records. (The 30-day deletion grace window is enforced — data-deletion/state.ts:68 + worker — that is a different mechanism.)

Spec promise: features.md 5705–5712 — per-data-class retention rules.

Fix sketch: a retention sweep worker (deletion-worker pattern) that walks the durable stores with retentionDaysFor per class; start with raw conversation turns at 30 days.


Severity: P2

Evidence: the BFF's live consent system is @oshun/contracts + consent/state.ts; libs/oshun/privacy/src/consent/consent.ts defines a parallel taxonomy/state machine (transitionConsent, defaultState, sensitivity rules) — zero app consumers. Same for compliance/compliance.ts (regimesFor, advanceBreachStage, resolveDisclosure, evaluateAuditCompleteness — the spec's breach-response clock and regime mapping), privacy-surface/privacy-surface.ts (buildPrivacyCenter, reading-grade check), and the dsar/ intake module (identity verification / jurisdictional eligibility). Only deletion-erasers + export-deletion's enqueueDeletion/advanceToSoftDelete are consumed (server.ts:40, privacy/dsar-erasure-route.ts:20).

Spec promise: features.md 5744–5758 (regulatory regimes, breach response detect→notify clock, audit completeness tests), 5728–5733 (DSAR intake with identity verification), 5714–5719 (privacy center).

What the code actually does: two consent vocabularies that can drift; no breach-response machinery anywhere in the runtime; DSAR intake is operator-constructed JSON rather than a verified customer intake.

Fix sketch: either consume these modules (privacy-center route over buildPrivacyCenter; DSAR intake route over dsar/) or collapse the duplicate taxonomy into @oshun/contracts and delete the rest — one source of truth.


19. Settlement HMAC is computed over a re-serialized body, not the raw payload#

Severity: P2

Evidence: settlement-route.ts:57const rawBody = JSON.stringify(request.body ?? {}) then HMAC-compares. The signature therefore depends on Fastify's parse + V8 key ordering matching the watcher's serialization byte-for-byte; any whitespace, key-order, or number formatting difference in the sender produces a spurious 401 (or forces the deploy-bound watcher to mirror this exact re-serialization quirk).

Spec promise: features.md 5569–5571 (reliable webhook bus).

Fix sketch: register a raw-body content parser for this route and HMAC the received bytes; keep the timing-safe compare.


20. UX — there is no privacy center: privacy controls are scattered across four surfaces, two of which are fixtures#

Severity: UX

Evidence: spec 5714–5719 promises "a single in-product surface listing every consent, memory state, export, deletion, residency choice, and audit-of-operator-access". Actual: /profile/data (real exports/deletions; fixture consent panel, no controls — finding 9), /profile/memory (real, C4), notification preferences only inside the /settings + /profile panel (components/ProfileSettingsPanel.tsx:61), while the parallel /profile/notifications page renders the prod-503 fixture /v1/profile/notifications with toggles that are "static reads" (app/profile/notifications/page.tsx:5,52 vs the real /v1/notifications/preferences PATCH route in routes/notifications-preferences.ts:283-298) — a dead duplicate surface. No residency-choice surface and no member-visible operator-access audit exist anywhere.

Fix sketch: make /profile/data the privacy center: real consent ledger + memory link + exports/deletions (already there) + residency display; delete or redirect /profile/notifications to the settings panel.


21. UX — the consumer billing surface has no plan, history, or receipt view#

Severity: UX

Evidence: /billing is a static link card (crypto link, profile link, AAA-upgrade link, refund mailto — app/billing/page.tsx). /billing/crypto renders a single invoice by id (paywall flow). No page shows the member's current plan, invoice history, or receipts even though the data exists (customerAuthStateStore plan; listCryptoInvoices — currently only exposed via the unauthenticated leak, finding 1). Spec 5374–5378 promises billing history, invoices/receipts, plan details.

Fix sketch: an authenticated /billing server component reading the session plan + a user-scoped invoice list (the corrected endpoint from finding 1) with per-invoice receipt links (finding 11).


Severity: UX

Evidence: (1) admin-app workspaces over the seeded per-operator state (apps/oshun/admin/src/app/trust-safety|support|privacy); (2) the @aja/content-moderation queue console (/v1/admin/moderation, web operator-depth.ts); (3) the living-scenes abuse inbox (/v1/admin/abuse-reports, severities S1/S2/S3 — routes/admin-abuse-reports.ts:18-32); (4) takedowns + infringers (/v1/admin/takedowns, /v1/admin/infringers). Severity is S1–S3 in one, priority 1–10 in another, P0–P3 in the orphaned trust-safety lib, and low…critical in the admin workspace. A customer user-report lands in console (1); a scene report in (3); a moderation flag in (2) — an operator triaging "abuse" must know which of four doors to open, and nothing links a user across them.

Fix sketch: adopt the trust-safety lib's P0–P3 as the shared vocabulary (finding 5 wiring), and add cross-links by subject userId/contentId between the four stores on each item view.


23. DEPLOY — recorded deploy-bound remainder (not findings)#

Severity: DEPLOY

For completeness, the residual deploy-bound set this slice confirmed: the chain watcher + invoice-target provisioner (quote route correctly 503s; payments-composition.ts:16-19), self-hosted node fleet + multi-RPC consensus

  • Tor rate-fetch egress (oracle-aggregator/ is engine-complete but its price-feed needs live exchange creds; note it is also unconsumed in-repo — when the provisioner lands, wire rate-lock into the quote builder rather than re-implementing), hardware-wallet co-signing, and BTCPay/LND infrastructure. OSHUN_CRYPTO_SETTLEMENT_WEBHOOK_SECRET, OSHUN_PAYMENTS_RECEIPT_* are env-gated creds = DEPLOY.

Severity counts#

Severity Count
P0-SEC 1
P0-HONESTY 1
P0-STRUCT 4
P1 9
P2 4
UX 3
DEPLOY 1
Total 23