How Lived is built, and how it scales

A complete architectural reference for the Lived peer-support marketplace: every layer of the current build as it exists in code, the gaps an engineering team needs to know about, and the target production architecture it should grow into.

Repository
THELIVEDAPP-LTD/lived-app, branch main at d5ae62e
Running environment
Demo/staging at lived-app.onrender.com, Render Frankfurt
Evidence base
Read-only code audit, 26 Sep 2026, plus locked product decisions
Company
THELIVEDAPP LIMITED, Ireland
Built and working Partial or has known defects Schema only Not present Target (decided or proposed)

The platform, layer by layer

Edge and delivery

DNS, TLS, CDN, request filtering

Render-managed TLS and routingonrender.com hostnameCloudflare holds thelivedapp.com, not cut overWAF and bot rulesEdge rate limitingSecurity headers
Cloudflare proxy on thelivedapp.comWAF, bot and rate rulesCDN for static assetsHSTS, CSP and frame headers

Presentation

Next.js App Router, React Server Components, client islands

13 pages, 2 layouts (RSC)20 components, 15 clientTailwind v4 brand tokensGuide portalSeeker account areaNotification centreError and loading boundariesVideo session UI
Seeker and guide portalsAdmin console with RBAC viewsIn-thread video sessionRealtime thread and badgesError, loading and not-found boundaries

Application

Mutations, orchestration, integration handlers

24 server actions in 10 modulesStripe confirmed during page renderStripe webhooksBackground jobs and cronEmail sending
Server actions (user intent)Signed webhook handlersWorker plus scheduled jobsTransactional outboxEmail and notification service

Domain and read models

Business rules, pure calculations, query composition

Read models in src/libPure refund, age, timezone mathsAggregates computed at query timeWallet as counter, no ledgerEarning and Payout models unused
Money ledger (earnings, payouts, fees)Policy and permission checks in one placeMaterialised guide statsMetrics read models

Data access

ORM, transactions, concurrency control

Prisma 7 with adapter-pgConditional updateMany guardsAdvisory lock on report intakeNo pool config, no pagination
Connection poolerCursor pagination everywhereRead replica for admin and analyticsIdempotency keys on every external call

Data stores

System of record and supporting stores

Postgres 16, basic-256mb, FrankfurtObject storageCache, queue, pub/subDeclared backups or PITR
Production Postgres with PITRRender Key Value (Redis)EU object storage, encryptedSeparate staging database

External services

Third parties the platform depends on

Stripe Checkout, Transfers, RefundsSentry (EU)Stripe Connect onboardingZeptoMailGoogle and Microsoft calendarsVideo providerRealtime provider
Stripe Connect with onboarding and webhooksZeptoMail transactional emailCalendar OAuth syncEmbedded video (provider TBD)Realtime transport

00How to read this

This page combines three kinds of information. Each claim is labelled so engineers know how much weight it can bear.

Verified in code

Everything in sections 01 to 21 comes from a read-only audit of the repository at commit d5ae62e (26 Sep 2026). File and line references such as booking-actions.ts:47 point into src/lib unless another path is given. Items the auditor could not read directly are marked inferred.

Decided, not yet built

Product rules the founder has locked (block behaviour, video session timing, realtime transport, retention periods) are shown with the Target status and cited as "locked decision". They are requirements, not suggestions.

Proposed

Sections 23 to 25 describe a target production architecture. Where the founder has not yet decided, the proposal is marked as such and listed in section 26 for sign-off.

The build in numbers

MeasureValueNote
Prisma models30Counted from schema.prisma. The audit text says 29; the schema file is authoritative.
Enums24Counted from schema.prisma (the audit text says 29).
Models with no application code touching them10Earning, Payout, PersonalDocument, CalendarConnection, DeletionRequest, AvailabilityMode, TrainingModule, GuideTrainingCompletion, Subcategory, SeekerPreferredCategory
Migrations9Two contain hand-written SQL (a data backfill and a partial unique index)
Pages and layouts13 pages, 2 layoutsEvery route renders dynamically
API route handlers1Auth.js only. No webhook or REST endpoints.
Server actions24The only mutation surface of the application
Automated tests0One manual verification rig for report intake
Commits35First commit 26 Aug 2026, latest 10 Sep 2026

Terms such as seeker, guide, intro, package, pay-what-you-can and hold-then-release are defined in the glossary.

01System context

Lived connects seekers (people going through something hard) with guides (vetted people who have lived through the same experience) for paid, pay-what-you-can or free one-to-one peer support. It is explicitly not therapy or professional advice. Today the whole platform is a single Next.js service and a single Postgres database on Render in Frankfurt, calling Stripe and Sentry.

flowchart LR
  S["Seeker"]:::actor
  G["Guide"]:::actor
  A["Admin / moderator"]:::actor
  subgraph R["Render, Frankfurt (EU)"]
    W["lived-app web service
Next.js 16, Node, starter plan"] D[("lived-db
Postgres 16, basic-256mb")] end ST["Stripe (sandbox)
Checkout, PaymentIntents,
Transfers, Refunds"] SE["Sentry (EU)
errors and traces"] CF["Cloudflare DNS
thelivedapp.com (not cut over)"] ZM["ZeptoMail"]:::off CAL["Google Calendar /
Microsoft Graph"]:::off VID["Video provider"]:::off S --> W G --> W A --> W W --> D W --> ST W --> SE CF -.-> W W -.->|"not wired"| ZM W -.->|"not wired"| CAL W -.->|"not chosen"| VID classDef actor fill:#e6efe8,stroke:#3d6048,color:#1c2620 classDef off fill:#f6e6e1,stroke:#a13f2a,color:#1c2620,stroke-dasharray:4 3

Current system context. Dashed nodes are declared in configuration but have no code behind them.

Actors

  • Seeker: any user with a SeekerProfile. Every signup creates one, including guide signups.
  • Guide: a user with a GuideProfile. Must be APPROVED to be visible and bookable.
  • Admin: User.isAdmin = true and ACTIVE. Only the seed sets this flag.

Data residency

Application, database and error monitoring all run in the EU (Render Frankfurt, Sentry Germany region). Stripe processes payments under its own EU entity. Any new processor (realtime, video, storage, email) must meet the same bar and be added to the retention register.

02Layers and request lifecycle

The application follows the standard App Router shape: server components read through a thin domain layer in src/lib, client components call server actions to mutate, and every write goes through Prisma. There is no separate API tier.

LayerWhere it livesResponsibilityStatus
Routing and pagessrc/app/**/page.tsx, layout.tsxServer components that load data and compose the screen. All routes use force-dynamic.Built
Client islandssrc/components/**Forms, pickers, modals. Local React state only; call server actions with useTransition or useActionState.Built
Server actionssrc/lib/*-actions.ts, src/lib/auth/actions.tsThe mutation API. Authenticate, validate by hand, run a Prisma transaction, call Stripe, revalidate paths. Return { ok, error } objects.Built
Read modelsguides.ts, booking.ts, dashboard.ts, threads.ts, moderation.ts, contributions.ts, credits.ts, categories.ts, report-reasons.tsQuery composition and computed values (ratings, session counts, bookability, refund previews).Built
Pure domain logiccancellation.ts, age.ts, timezone.tsSide-effect-free maths: refund fractions, 18+ gate, slot timing.Built
Constants and copycredit-packs.ts, signup-constants.ts, policy-content.ts, terms-content.ts, badges.ts, config.tsPrices, limits, policy copy, badge explanations, demo flag.Built
Data accesssrc/lib/db.ts, generated client in src/generated/prismaSingleton PrismaClient over @prisma/adapter-pg. Cached on globalThis outside production.No pool config
Integration clientssrc/lib/stripe.ts, src/instrumentation*.tsStripe SDK client and platform fee rate; Sentry initialisation.Stripe throws at import if key unset
Webhooks, jobs, emailNoneAsynchronous work of any kind.Not present

A typical request

Every page render pays a fixed cost before its own data loads: the root layout is dynamic and SiteHeader calls auth(), getCategories(), isAdminUser() and isSeekerUser() on every request (three database queries, three session reads, no memoisation).

sequenceDiagram
  autonumber
  participant B as Browser
  participant N as Next.js (Render)
  participant L as src/lib read models
  participant P as Prisma
  participant DB as Postgres
  B->>N: GET /guides/[id]
  N->>N: root layout, auth() reads JWT cookie
  N->>P: header queries (categories, isAdmin, isSeeker)
  P->>DB: 3 queries
  N->>L: getGuideProfile, getBookingContext, getReportReasons
  L->>P: queries with includes
  P->>DB: SELECT ...
  N-->>B: streamed RSC payload and HTML
  B->>N: server action bookIntro(guideId, slot)
  N->>N: requireSeeker() and validation
  N->>P: $transaction (re-check slot, create Booking, claim slot, MetricEvent)
  P->>DB: BEGIN ... COMMIT
  N->>N: revalidatePath("/guides/[id]")
  N-->>B: { ok: true } then router refresh
  

Read then mutate. Server actions are the only write path, apart from three Stripe confirmations that run during page render (section 09).

Architectural patterns in use

PatternWhereWhy it existsConsequence for the team
Server actions as the only mutation APIAll of src/lib/*-actions.tsOne language and one deploy unit for a solo founderNo public API for mobile apps or partners yet. Every action must do its own authorisation.
Confirm-on-render for Stripe CheckoutconfirmPackageCheckout, confirmContributionCheckout, confirmCreditsCheckoutAvoids webhooks in the demoIf the tab closes before redirect, the payment is never recorded. Launch blocker (F02).
Computed, not stored, aggregatesguides.ts (rating, sessionCount, bookability)Fixed metric drift in the prototypeCorrect but expensive; loads every review and completed booking per guide on Discover (F18).
Conditional updateMany as a compare-and-setsendMessage, confirmCreditsCheckoutSerializable isolation raised SQLSTATE 25001 on adapter-pg and was rejectedThe pattern to copy for any new counter or state claim.
Transaction-scoped advisory lock plus partial unique indexcreateReport, migration 9Close the duplicate-report race at both app and DB levelThe index is invisible to Prisma; migrate dev may try to drop it (F22).
Loose references without foreign keysReport, MetricEvent, EarningReports and metrics must survive user deletion for legal defence and analyticsReferential integrity is the application's job for these columns.
Lazy state transitions on readgetPendingContributions flips PROMPTED to EXPIREDNo job runnerState only changes when someone views it. Replace with scheduled jobs.
Stripe calls outside DB transactionscancelBooking (before), completeBooking and contributions (after)Avoid holding a transaction open across a network callNo compensation or retry if one side fails (F04, F05).

03Stack and dependencies

A deliberately small dependency set: ten runtime packages. Versions below are the resolved versions in package-lock.json.

Runtime

PackageVersionRole
next16.3.3App Router framework, server components, server actions
react, react-dom19.2.8UI runtime
@prisma/client7.10.0ORM runtime; client generated to src/generated/prisma with the prisma-client generator
@prisma/adapter-pg, pg7.10.0, 8.23.0Prisma 7 driver adapter over node-postgres
next-auth5.0.0-beta.32Auth.js v5: credentials provider, JWT sessions, no DB adapter
bcryptjs3.0.3Password hashing, cost 10
stripe22.5.0Checkout Sessions, PaymentIntents, Transfers, Refunds
@sentry/nextjs10.71.0Error monitoring and tracing

Development

PackageVersionRole
prisma7.10.0CLI: generate, migrate, seed
tailwindcss, @tailwindcss/postcss4.3.3Styling; tokens in @theme, no config file
typescript5.9.3Strict mode, @/* maps to ./src/*
eslint, eslint-config-next9.39.5, 16.3.3Core web vitals and TypeScript presets
tsx4.23.12Runs the seed and the verification rig
dotenv17.4.2Loads .env for Prisma config and seed
@types/node, @types/pg, @types/react, @types/react-dom20, 8, 19, 19Type definitions

Scripts

ScriptRunsUsed by
dev / build / startnext dev / next build / next startLocal, Render build, Render start
postinstallprisma generateRuns automatically after npm install
db:migrate:devprisma migrate devLocal schema changes
db:migrate:deployprisma migrate deployRender preDeployCommand on every deploy
db:seedprisma db seed (runs tsx prisma/seed.ts)Manual, via Render Shell
linteslintManual

Node is not pinned. There is no engines field, .nvmrc or NODE_VERSION. Dependencies require Node 20.19+ or 22.12+. Render will use its default, which can change underneath a deploy. Pin it (F21).

04Repository structure

113 tracked files. Generated and vendored folders are excluded below (node_modules, .next, .git, src/generated/prisma, and the gitignored Prisma agent-skill docs in .agents, .claude/skills, .windsurf).

lived-app/
├── CLAUDE.md                     working rules, launch blockers, done list, roadmap
├── AGENTS.md                     auto-generated Next.js agent note
├── README.md                     overview, local setup, schema notes (refers to a missing lib/payments.ts)
├── render.yaml                   Render Blueprint: lived-db + lived-app web service
├── next.config.ts                empty config wrapped in withSentryConfig
├── prisma.config.ts              Prisma 7 config: schema path, migrations, seed command, DATABASE_URL
├── tsconfig.json                 strict, bundler resolution, @/* alias
├── eslint.config.mjs             flat config, next presets, ignores src/generated
├── postcss.config.mjs            @tailwindcss/postcss
├── .env.example                  env var names and comments
├── .claude/settings.local.json   Claude Code command allowlist
├── docs/verification/
│   ├── 2026-08-29-payments.md            hold-then-release and refund matrix record
│   ├── 2026-08-31-credits.md             credit purchase round trip, double-credit guard
│   └── 2026-08-31-out-of-credits-ui.md   out-of-credits UI record
├── prisma/
│   ├── schema.prisma             30 models, 24 enums
│   ├── seed.ts                   idempotent seed; demo data only when IS_DEMO=true
│   ├── seed-data/categories.ts   23 categories, 153 subcategories, risk flags
│   └── migrations/               9 migrations + migration_lock.toml
├── public/
│   ├── lived-logo.png            brand logo
│   └── file/globe/next/vercel/window.svg   unused scaffold assets
├── scripts/verification/report-guard/
│   ├── README.md                 purpose, usage, PGlite caveat
│   ├── run.ts                    runs real createReport: single | dup | race | clean
│   └── stub-loader.mjs           stubs next/cache and auth for Node execution
└── src/
    ├── instrumentation.ts        server and edge Sentry init, onRequestError
    ├── instrumentation-client.ts browser Sentry init
    ├── app/
    │   ├── layout.tsx            fonts, demo banner, header, footer (force-dynamic)
    │   ├── page.tsx              marketing home, featured guides
    │   ├── globals.css           Tailwind import and @theme tokens
    │   ├── icon.png, apple-icon.png
    │   ├── api/auth/[...nextauth]/route.ts   Auth.js handlers (only API route)
    │   ├── admin/layout.tsx      admin gate, emergency banner
    │   ├── admin/moderation/page.tsx         queue by lifecycle state
    │   ├── admin/moderation/[id]/page.tsx    case detail and controls
    │   ├── bookings/page.tsx     my bookings (both roles), prompts, contribution confirm
    │   ├── cancellation-policy/page.tsx
    │   ├── discover/page.tsx     guide discovery
    │   ├── guides/[id]/page.tsx  guide profile, booking panel, package checkout confirm
    │   ├── login/page.tsx
    │   ├── messages/page.tsx     thread list
    │   ├── messages/[threadId]/page.tsx   thread view, compose, report
    │   ├── signup/guide/page.tsx
    │   ├── signup/seeker/page.tsx
    │   ├── terms/page.tsx
    │   └── wallet/page.tsx       credits balance, buy packs, credits confirm
    ├── components/
    │   ├── admin/case-controls.tsx, admin/resolve-form.tsx
    │   ├── badge-chip.tsx, badge-popover.tsx, category-pill.tsx
    │   ├── booking/booking-panel.tsx
    │   ├── bookings/cancel-button.tsx, complete-button.tsx, contribution-prompt.tsx
    │   ├── discover/discover-client.tsx, guide-card.tsx
    │   ├── login-form.tsx
    │   ├── messages/compose-box.tsx
    │   ├── report/report-button.tsx
    │   ├── signup/guide-signup-form.tsx, seeker-signup-form.tsx
    │   ├── site-header.tsx, site-nav.tsx, site-footer.tsx
    │   └── wallet/buy-credits.tsx
    └── lib/
        ├── auth/auth.ts          Auth.js config
        ├── auth/actions.ts       loginAction, logoutAction
        ├── auth/admin.ts         getAdmin, isAdminUser
        ├── auth/seeker.ts        isSeekerUser (nav only)
        ├── auth/next-auth.d.ts   session.user.id type
        ├── db.ts                 Prisma singleton
        ├── stripe.ts             Stripe client, PLATFORM_FEE_RATE
        ├── config.ts             isDemoMode
        ├── booking.ts, booking-actions.ts
        ├── cancellation.ts, cancellation-actions.ts
        ├── contributions.ts, contribution-actions.ts
        ├── credits.ts, credit-packs.ts, credit-actions.ts
        ├── message-actions.ts, threads.ts
        ├── moderation.ts, moderation-actions.ts, report-reasons.ts
        ├── session-actions.ts, dashboard.ts, guides.ts, categories.ts
        ├── signup-actions.ts, signup-constants.ts, age.ts
        ├── badges.ts, policy-content.ts, terms-content.ts
        └── timezone.ts

Not in the repository: middleware.ts, error and loading boundaries, any API route besides Auth.js, lib/payments.ts (referenced by README and .env.example), tests, CI configuration, a Dockerfile, the HTML prototype, and the build spec (both live outside the repo in ~/Desktop/lived/).

Hand-off action: move the build spec and category taxonomy into docs/ so the repository is self-describing. CLAUDE.md already claims the spec is in the repo.

05Data model

Thirty Prisma models across six domains, on Postgres 16. IDs are cuid() strings generated by the client, every table has snake_case naming via @@map, money is Decimal(10,2) in EUR, and every foreign key cascades on update. Where no onDelete is written, Prisma's defaults apply: RESTRICT for required relations, SET NULL for optional ones.

Identity and accounts

erDiagram
  USER ||--o| SEEKER_PROFILE : "has"
  USER ||--o| GUIDE_PROFILE : "has"
  USER ||--o| WALLET : "has"
  USER ||--o{ CREDIT_PURCHASE : "buys"
  USER ||--o{ POLICY_ACCEPTANCE : "accepts"
  USER ||--o{ PERSONAL_DOCUMENT : "uploads"
  USER ||--o{ CALENDAR_CONNECTION : "connects"
  USER ||--o{ NOTIFICATION : "receives"
  USER ||--o{ DELETION_REQUEST : "requests"
  USER |o--o{ CATEGORY : "proposes"
  USER {
    string id PK
    string email UK
    string passwordHash
    string name
    date dateOfBirth
    AccountStatus accountStatus
    UserRole lastActiveRole
    boolean isAdmin
    datetime deletionConfirmedAt
  }
  WALLET {
    string userId PK
    int creditBalance
  }
  CREDIT_PURCHASE {
    string id PK
    string packKey
    int credits
    decimal amountEur
    CreditPurchaseStatus status
    string stripeCheckoutSessionId UK
    string stripePaymentIntentId UK
  }
  

Guide supply and catalogue

erDiagram
  GUIDE_PROFILE ||--o{ GUIDE_CATEGORY : "listed in"
  CATEGORY ||--o{ GUIDE_CATEGORY : "contains"
  CATEGORY ||--o{ SUBCATEGORY : "has"
  CATEGORY ||--o{ SEEKER_PREFERRED_CATEGORY : "preferred by"
  SEEKER_PROFILE ||--o{ SEEKER_PREFERRED_CATEGORY : "prefers"
  GUIDE_PROFILE ||--o{ GUIDE_BADGE : "holds"
  PERSONAL_DOCUMENT |o--o{ GUIDE_BADGE : "evidences"
  GUIDE_PROFILE ||--o{ SESSION_PACKAGE : "offers"
  GUIDE_PROFILE ||--o{ AVAILABILITY_SLOT : "publishes"
  GUIDE_PROFILE ||--o| AVAILABILITY_MODE : "uses"
  GUIDE_PROFILE ||--o{ GUIDE_TRAINING_COMPLETION : "completes"
  TRAINING_MODULE ||--o{ GUIDE_TRAINING_COMPLETION : "completed as"
  GUIDE_PROFILE {
    string id PK
    string ownerId UK
    GuideStatus status
    GuidePricingModel pricingModel
    decimal rateEur
    string stripeAccountId
    boolean stripeChargesEnabled
    string country
    string story
  }
  CATEGORY {
    string id PK
    string name UK
    RiskLevel riskLevel
    boolean requiresEnhancedVetting
    boolean requiresAdvancedSafeguardingBadge
    boolean autoCheckIn
    CategoryStatus status
  }
  AVAILABILITY_SLOT {
    string id PK
    date date
    string time
    string bookingId UK
  }
  

Bookings and money

erDiagram
  SEEKER_PROFILE ||--o{ BOOKING : "books"
  GUIDE_PROFILE ||--o{ BOOKING : "hosts"
  SESSION_PACKAGE |o--o{ BOOKING : "priced by"
  BOOKING |o--o| AVAILABILITY_SLOT : "occupies"
  BOOKING ||--o| CONTRIBUTION : "prompts"
  BOOKING ||--o| REVIEW : "reviewed by"
  GUIDE_PROFILE ||--o{ EARNING : "earns"
  GUIDE_PROFILE ||--o{ PAYOUT : "is paid"
  BOOKING {
    string id PK
    BookingType type
    BookingStatus status
    date dateKey
    string hour
    decimal amountEur
    decimal platformFeeEur
    string stripeCheckoutSessionId UK
    string stripePaymentIntentId UK
    string stripeTransferId UK
    string stripeRefundId UK
    CancelledBy cancelledBy
    decimal refundFraction
    decimal refundEur
  }
  CONTRIBUTION {
    string id PK
    string bookingId UK
    ContributionStatus status
    decimal amountEur
    datetime expiresAt
    string stripeCheckoutSessionId UK
    string stripeTransferId UK
  }
  

Messaging, safety and compliance

erDiagram
  SEEKER_PROFILE ||--o{ THREAD : "participates"
  GUIDE_PROFILE ||--o{ THREAD : "participates"
  THREAD ||--o{ MESSAGE : "contains"
  REPORT_REASON |o--o{ REPORT : "classifies"
  REPORT }o..o| THREAD : "threadId, no FK"
  REPORT }o..|| USER : "reporterId, reportedId, no FK"
  METRIC_EVENT }o..o| USER : "loose ids, no FK"
  THREAD {
    string id PK
    int seekerMsgCount
    int guideMsgCount
    boolean seekerHasUnread
    boolean guideHasUnread
    boolean legalHold
  }
  MESSAGE {
    string id PK
    MessageSender sender
    varchar250 text
  }
  REPORT {
    string id PK
    ReportStatus status
    ReportSeverity severity
    string reasonKey FK
    boolean isEmergency
    EnforcementAction enforcementAction
    json transcriptSnapshot
    string retentionBasis
    boolean legalHold
  }
  

Dotted relationships are logical references stored as plain strings. They survive user deletion by design.

Model catalogue

Model (table)DomainKey fields and constraintsCode usage
User (users)Identityemail unique; passwordHash nullable; dateOfBirth date; accountStatus default ACTIVE; isAdmin default false; deletionConfirmedAt; lastActiveRoleActive lastActiveRole and deletionConfirmedAt unused
SeekerProfile (seeker_profiles)IdentityownerId unique, RESTRICT; lookingFor, country, city, location, languagesActive profile fields not editable in UI
SeekerPreferredCategoryCatalogueComposite PK (seekerId, categoryId); seeker CASCADE, category RESTRICTUnused
GuideProfile (guide_profiles)SupplyownerId unique; status default PENDING; pricingModel default PAID; rateEur; stripeAccountId; stripeChargesEnabled; story, bestFitFor, notAFitIf, tags, style; show flags for pronouns and locationActive most fields seed-only; no edit UI
GuideCategory (guide_categories)SupplyComposite PK (guideId, categoryId); index on categoryId; 1 to 5 per guide enforced in appActive
GuideBadge (guide_badges)TrustUnique (guideId, type); optional evidenceDocumentId SET NULLRead only awarded by seed only
Category (categories)Cataloguename unique; riskLevel; disclaimerLevel; autoCheckIn; requiresEnhancedVetting; requiresAdvancedSafeguardingBadge; isCustom; status; proposedBy; aiSuggestedRiskLevel; sortOrderActive risk and vetting flags never read
Subcategory (subcategories)CatalogueUnique (categoryId, name); riskLevelOverrideSeed only
SessionPackage (session_packages)SupplyUnique (guideId, durationMinutes); priceEur nullable; isActiveRead only created by seed; no guide UI
AvailabilitySlot (availability_slots)SupplyUnique (guideId, date, time); time is "HH:MM" text; bookingId unique, SET NULLActive created by seed only; booking claims and frees
AvailabilityModeSupplyPK guideId; mode free text default "manual"Unused
Booking (bookings)Bookingstype INTRO or PACKAGE; status; dateKey and hour; amounts; four unique Stripe ids; cancellation fields; index (seekerId, guideId, type). One intro per pair is app-only.Active
Thread (threads)MessagingUnique (seekerId, guideId); message counters; unread flags; legalHoldActive no code creates threads
Message (messages)Messagingtext VarChar(250); sender; thread CASCADEActive
Wallet (wallets)MoneyPK userId; creditBalance Int. A counter, not a ledger.Active created lazily on first purchase
CreditPurchase (credit_purchases)MoneypackKey, credits, amountEur; status PENDING or COMPLETED; checkout session id unique and requiredActive no FAILED or EXPIRED state
Earning (earnings)Moneygross, fee, net; bookingId and contributionId as plain stringsUnused
Payout (payouts)MoneyamountEur; status PENDING, PAID, FAILEDUnused
Contribution (contributions)MoneybookingId unique; status; amountEur; promptedAt, respondedAt, expiresAt; three unique Stripe ids. No fee column.Active
Review (reviews)TrustbookingId unique; rating Int; emotion; commentRead only no submission flow
TrainingModule (training_modules)Trustkey unique; required; needsDocument; badgeAwardedSeed only 6 modules
GuideTrainingCompletionTrustComposite PK (guideId, trainingModuleId)Seed only
PolicyAcceptance (policy_acceptances)CompliancepolicyKey, version, acceptedAtWrite only written at signup, never read
ReportReason (report_reasons)SafetyPK key; label; description; isEmergency; active; sortOrderActive 10 seeded reasons
Report (reports)SafetyCase model: status, severity, assignedReviewer, reasonKey FK, detail, notes, outcome, enforcementAction, responseDeadline, notified flags, isEmergency, transcriptSnapshot jsonb, retentionBasis, legalHold, appeal fields. Party ids without FK. Partial unique index outside Prisma.Active responseDeadline never written
PersonalDocument (personal_documents)Trusttype; status; fileUrl nullable for post-sweep clearingUnused
CalendarConnection (calendar_connections)IntegrationsUnique (userId, provider); accessToken and refreshToken as plaintext columnsUnused must encrypt before use
Notification (notifications)Commstype free text; title; body; readAtWrite only never displayed
DeletionRequest (deletion_requests)Compliancestatus REQUESTED, PROCESSING, COMPLETED; deletionConfirmedAtUnused
MetricEvent (metric_events)Analyticstype; loose user, guide, seeker, booking ids; amountEur; metadata jsonb; index (type, occurredAt). Append-only by convention.Write only no consumer
Enums (24) and where each is used
EnumValuesUsage
AccountStatusACTIVE, SUSPENDED, PENDING_DELETION, DELETEDACTIVE checked at login and in getAdmin. SUSPENDED set by moderation. PENDING_DELETION never used; DELETED only read.
UserRoleSEEKER, GUIDEOnly for lastActiveRole, which no code reads or writes
GuideBadgeTypeSAFETY_TRAINED, ID_VERIFIED, LIVED_EXPERIENCE, ADVANCED_SAFEGUARDINGDisplayed; SAFETY_TRAINED gates Discover. Nothing awards badges.
RiskLevelSTANDARD, SENSITIVE, HIGH_RISKSeed only
DisclaimerLevelSTANDARD, STRONGSeed only
CategoryStatusPENDING, APPROVED, REJECTEDAPPROVED filters catalogue and signup
GuideStatusPENDING, APPROVED, REJECTED, SUSPENDEDPENDING default; APPROVED gates visibility and booking; SUSPENDED by moderation; REJECTED unused
GuidePricingModelPAID, PAY_WHAT_YOU_CAN, FREEBooking rules, pricing labels, contribution prompts
BookingTypeINTRO, PACKAGEBooking, completion, dashboard
BookingStatusCONFIRMED, CANCELLED, COMPLETEDAll values written
CancelledBySEEKER, GUIDERefund maths and cancel UI
MessageSenderSEEKER, GUIDEMessages, transcripts
CreditPurchaseStatusPENDING, COMPLETEDCredit checkout
PayoutStatusPENDING, PAID, FAILEDUnused
ContributionStatusPROMPTED, MADE, SKIPPED, EXPIREDAll values written
ReportStatusSUBMITTED, UNDER_REVIEW, ACTIONED, DISMISSEDModeration lifecycle
AppealStatusPENDING, UPHELD, REJECTEDUPHELD never written
ReportSeverityLOW, MEDIUM, HIGHAdmin controls
EnforcementActionNONE, WARNING, TEMPORARY_SUSPENSION, PERMANENT_REMOVAL, CONTENT_REMOVED, ESCALATED_EMERGENCYResolve form and enforcement
DocumentTypeID_VERIFICATION, LIVED_EXPERIENCE_EVIDENCE, OTHERUnused
DocumentStatusPENDING, ACCEPTED, REJECTEDUnused
CalendarProviderGOOGLE, MICROSOFTUnused
DeletionRequestStatusREQUESTED, PROCESSING, COMPLETEDUnused
MetricEventType19 values (section 12)11 emitted, 8 never emitted

Migrations

#MigrationChangeHand-written SQL
120260825225744_initAll original enums, tables, indexes and FKs, including single guide_profiles.categoryId and required reports.reasonNo
220260826140000_add_stripe_connect_fieldsGuide Stripe account fields; booking checkout session id (unique)No
320260826150000_add_contribution_checkout_sessionContribution checkout session id (unique)No
420260827000000_add_hold_then_release_fieldsBooking refund, payment intent, refund and transfer ids; contribution intent and transfer ids; all uniqueNo
520260829000000_add_user_is_adminusers.isAdminNo
620260829120000_add_report_reasonsreport_reasons table; reports gain reasonKey FK and detail; reason becomes nullableHand-edited DDL
720260830000000_guide_multi_categoryCreates guide_categories, backfills from guide_profiles.categoryId, then drops the columnYes, data backfill
820260831000000_add_credit_purchasesAdds CREDITS_PURCHASED metric; credit purchase enum and tableALTER TYPE ... ADD VALUE
920260907000000_report_open_duplicate_guardPartial unique index reports_one_open_per_reasonYes, not representable in Prisma schema

Database objects outside Prisma. The partial unique index reports_one_open_per_reason on (reporterId, reportedId, reasonKey) where status is SUBMITTED or UNDER_REVIEW, reasonKey is set and not other. Prisma does not know it exists; a future migrate dev can generate a DROP for it. Report intake also takes a runtime pg_advisory_xact_lock(hashtext(reporterId), hashtext(reportedId)) through $executeRaw (not $queryRaw, which broke intake in commit 7b8f864). There are no triggers, functions, views, extensions or row-level security policies.

Values computed at read time

ValueRuleComputed in
Guide ratingMean of Review.rating, or nullguides.ts
Session countCount of COMPLETED bookingsguides.ts
Discover visibilityAPPROVED and holds SAFETY_TRAINEDgetDiscoverGuides only; the profile page and booking actions check APPROVED alone (F07)
introRequired, hasIntro, canBookPaidPackageNot FREE; an intro CONFIRMED or COMPLETED; not PAID, or Stripe account connected and charges enabledbooking.ts
Available slotsUnbooked and in the future in the guide's country timezonebooking.ts, timezone.ts
Refund fraction, refund and transfer amountsRefund matrix; stored on the booking at cancel timecancellation.ts
Platform fee15% of gross; stored on the bookingstripe.ts, booking-actions.ts
Out-of-credits blockSeeker, 5 or more seeker messages, balance 0Thread page; enforced in sendMessage
Contribution suggestions0.5x, 1x and 1.5x the package price (fallback €15), minimum €1contributions.ts
Repeat-offence flag3 or more ACTIONED reports, or 1 non-dismissed emergencymoderation.ts

Stored counters, maintained by writes: Thread.seekerMsgCount, guideMsgCount, both unread flags, and Wallet.creditBalance.

06State machines

Every status field, its transitions, what triggers them and the guards that apply. All application writes live in src/lib; nothing in src/app or src/components writes directly.

Booking

stateDiagram-v2
  direction LR
  [*] --> CONFIRMED: bookIntro (free intro)
  [*] --> CONFIRMED: bookPackage (FREE or PWYC guide)
  [*] --> Checkout: bookPackage (PAID guide)
  Checkout --> CONFIRMED: confirmPackageCheckout on return page
  Checkout --> [*]: abandoned, nothing recorded
  CONFIRMED --> COMPLETED: completeBooking (guide)
  CONFIRMED --> CANCELLED: cancelBooking (either party)
  COMPLETED --> [*]
  CANCELLED --> [*]
  
TransitionFunctionGuardsGaps
New intro, CONFIRMEDbookIntro (booking-actions.ts:47)Seeker profile; guide APPROVED and not FREE; open slot; no existing CONFIRMED or COMPLETED intro for the pair; slot re-read in transactionNo future-time check on the slot; self-booking allowed; SAFETY_TRAINED and accountStatus not checked; one-intro rule not enforced by the DB
New package (FREE, PWYC), CONFIRMEDbookPackage (:108)Intro required unless FREE; active package owned by guide; slot re-checkSame as above
PAID package to CheckoutbookPackage (:157)Package priced; guide Stripe account connected and charges enabledNo DB row until return
Checkout to CONFIRMEDconfirmPackageCheckout (:251)Idempotent on checkout session id; session paid; package active; slot openNo caller auth; guide id from URL not matched to metadata; guide status unchecked; amount taken from package, not amount_total; lost slot means paid with no booking and no refund
CONFIRMED to COMPLETEDcompleteBooking (session-actions.ts:22)Owning guide; status CONFIRMEDNo check that the session time has passed; transfer runs after commit and cannot be retried
CONFIRMED to CANCELLEDcancelBooking (cancellation-actions.ts:90)Booking participant; CONFIRMED; payment intent present when chargedStripe refund and transfer before DB write, no conditional claim and no idempotency key

For pay-what-you-can bookings the schema says amountEur is filled once the contribution resolves. No code does this, so PWYC bookings always look uncharged.

Contribution (pay-what-you-can)

stateDiagram-v2
  direction LR
  [*] --> PROMPTED: completeBooking, PWYC package, expires in 7 days
  PROMPTED --> MADE: makeContribution with 0 EUR
  PROMPTED --> Checkout: makeContribution above 0
  Checkout --> MADE: confirmContributionCheckout, then 85 percent transfer
  PROMPTED --> SKIPPED: skipContribution
  PROMPTED --> EXPIRED: lazily when the seeker opens /bookings
  

Ownership guards apply to make and skip. The paid confirmation has no caller check, takes the transfer destination from the guide_id URL parameter, does not check expiry, and updates without a conditional claim (F01).

Credit purchase and wallet

stateDiagram-v2
  direction LR
  [*] --> PENDING: startCreditsCheckout creates session then row
  PENDING --> COMPLETED: confirmCreditsCheckout, atomic claim, wallet upsert
  PENDING --> PENDING: tab closed or unpaid, stays forever
  

Wallet.creditBalance goes up in confirmCreditsCheckout and down by one in sendMessage when the seeker has used their five free messages, via an updateMany guarded by creditBalance > 0. Spends are not recorded individually.

Report (moderation case)

stateDiagram-v2
  direction LR
  [*] --> SUBMITTED: createReport (lock, per-reason guard, emergency routing)
  SUBMITTED --> UNDER_REVIEW: claimReport (admin)
  UNDER_REVIEW --> ACTIONED: resolveReport with enforcement
  UNDER_REVIEW --> DISMISSED: resolveReport with NONE
  ACTIONED --> UNDER_REVIEW: markAppealed
  DISMISSED --> UNDER_REVIEW: markAppealed
  
  • Severity: HIGH on emergency creation, otherwise LOW; changed by setSeverity or escalateToEmergency.
  • Emergency: only ever set to true (at creation, on escalation, or by ESCALATED_EMERGENCY). Sets legalHold and notifies every active admin.
  • Appeal: markAppealed sets PENDING and reopens; resolveReport then always writes REJECTED, even if the outcome changes. UPHELD is never written (F10).
  • Enforcement: suspension or removal sets user SUSPENDED and guide SUSPENDED; CONTENT_REMOVED sets thread legal hold only; WARNING and NONE notify only. There is no distinct "removed" state.
  • Lift: liftEnforcement sets user ACTIVE and guide APPROVED regardless of the guide's previous status, and records nothing on the report (F09).

Account and guide status

stateDiagram-v2
  [*] --> ACTIVE: signup
  ACTIVE --> SUSPENDED: applyEnforcement
  SUSPENDED --> ACTIVE: liftEnforcement
  ACTIVE --> PENDING_DELETION: not built
  PENDING_DELETION --> DELETED: not built
    
stateDiagram-v2
  [*] --> PENDING: guide signup
  PENDING --> APPROVED: seed only, no admin action
  PENDING --> REJECTED: never written
  APPROVED --> SUSPENDED: applyEnforcement
  SUSPENDED --> APPROVED: liftEnforcement
    

Account status is enforced only at login and in getAdmin(). JWT callbacks never re-read the database, so a suspended user keeps full access until their token expires (default 30 days, inferred). A code comment claims the session dies on the next request; it does not (F03).

Other stateful fields

  • Thread: counters and unread flags change on send; the viewer's unread flag clears when they open the thread; legalHold is set by emergencies and content removal and never cleared. No application code creates threads.
  • Training, badges, documents: seed only. Nothing aggregates module completion into SAFETY_TRAINED.
  • Blocks: no model, enum or code.
  • Never written by the app: Category PENDING and REJECTED, Payout, Earning, DeletionRequest, CalendarConnection, AvailabilityMode, User.lastActiveRole, Review (seed only), Notification.readAt, SessionPackage and AvailabilitySlot creation (seed only).

07Routes and server actions

Thirteen pages, two layouts and one Auth.js route handler. There is no middleware, no error.tsx, not-found.tsx or loading.tsx, and every route is dynamic because the root layout sets force-dynamic.

PathAreaAccessReadsWrites during render
/PublicAnyoneDiscover guides (first three shown), categoriesNone
/discoverPublicAnyoneAll bookable guides; filtering by category, language and text happens in the browserNone
/guides/[id]PublicAnyone; APPROVED guides only, else 404. Report button for logged-in non-owners.Guide profile, booking context, report reasons, existing threadconfirmPackageCheckout when ?checkout_session_id is present, with no auth
/terms, /cancellation-policyPublicAnyoneStatic content modulesNone
/loginAuthAnyone; logged-in users are not redirectedDemo credentials hint when IS_DEMONone
/signup/seeker, /signup/guideAuthAnyoneApproved categories (guide form)None
/api/auth/[...nextauth]AuthAuth.jsSession, CSRF token, sign-in callback, sign-out
/walletSeekerLogged in with seeker profile; otherwise an inline messageWallet balanceconfirmCreditsCheckout plus revalidatePath from inside render
/bookingsSharedLogged in; inline message otherwiseBookings as seeker and as guide, follow-up prompts, pending contributionsconfirmContributionCheckout; lazy contribution expiry
/messagesMessagingLogged inThreads with last message and countNone
/messages/[threadId]MessagingParticipants only, else 404. No admin override.Thread, messages, reasons, wallet balance for the out-of-credits statemarkThreadRead
/admin/* layoutAdmingetAdmin(), else 404Open emergencies bannerNone
/adminAdminNo page exists (404)NoneNone
/admin/moderationAdminAdminAll reports grouped by status, repeat-offence flagsNone
/admin/moderation/[id]AdminAdminCase, both parties including email, transcript snapshot, other casesNone

Missing areas: guide dashboard, profile editing, availability and package management, Stripe Connect onboarding, training, document upload, seeker account and settings, notifications, video sessions, admin areas beyond moderation.

Server actions

ActionModuleCalled fromAuthorisationWritesRevalidates
loginActionauth/actions.tsLoginFormPublicJWT cookieRedirect /discover
logoutActionauth/actions.tsSiteNavNone neededClears cookieRedirect /discover
createSeekerAccountsignup-actions.tsSeeker signup formPublic, validatedUser, SeekerProfile, PolicyAcceptanceAuto sign-in
createGuideAccountsignup-actions.tsGuide signup formPublic, validatedUser, SeekerProfile, GuideProfile, GuideCategory, PolicyAcceptanceAuto sign-in
bookIntrobooking-actions.tsBookingPanelSeekerBooking, slot, MetricEvent/guides/[id]
bookPackagebooking-actions.tsBookingPanelSeekerBooking, slot, MetricEvent, or Stripe redirect/guides/[id]
confirmPackageCheckoutbooking-actions.tsRender of /guides/[id]NoneBooking, slot, MetricEventNone
completeBookingsession-actions.tsCompleteButtonOwning guideBooking, MetricEvent, Contribution, then Stripe transfer id/bookings
previewCancellationcancellation-actions.tsCancelButtonParticipantNoneNone
cancelBookingcancellation-actions.tsCancelButtonParticipantStripe refund and transfer, Booking, slot, MetricEvent/bookings
makeContributioncontribution-actions.tsContributionPromptOwning seekerContribution and MetricEvent, or Stripe redirect/bookings
skipContributioncontribution-actions.tsContributionPromptOwning seekerContribution, MetricEvent/bookings
confirmContributionCheckoutcontribution-actions.tsRender of /bookingsNoneContribution, MetricEvent, Stripe transferNone
startCreditsCheckoutcredit-actions.tsBuyCreditsSeekerCreditPurchase PENDINGStripe redirect
confirmCreditsCheckoutcredit-actions.tsRender of /walletNone (credits the purchase owner)CreditPurchase, Wallet, MetricEvent/wallet
sendMessagemessage-actions.tsComposeBoxThread participantThread counters and flags, Wallet, Message/messages, /messages/[id]
createReportmoderation-actions.tsReportButtonLogged in, not selfReport, Notification, Thread.legalHoldAdmin paths
claimReport, saveInvestigationNotes, setSeverity, escalateToEmergency, markAppealedmoderation-actions.tsCaseControlsAdminReport fields, notificationsAdmin paths
resolveReport, liftEnforcementmoderation-actions.tsResolveForm, CaseControlsAdminReport, User, GuideProfile, Notification, MetricEventAdmin paths, /discover

Because the three confirm functions are exported from "use server" modules, Next.js may also expose them as callable action endpoints (inferred, not verified against the action manifest).

08Auth, access control and onboarding

Auth.js v5 with a credentials provider and stateless JWT sessions. Roles are derived from which profiles a user owns, plus a single admin flag. All gating happens in pages, layouts and actions; there is no middleware.

Authentication

  • Credentials only; authorize looks up the email, compares with bcrypt (cost 10) and rejects non-ACTIVE accounts.
  • JWT strategy, trustHost: true, sign-in page /login. The token carries only the user id.
  • Env: AUTH_SECRET, AUTH_URL (must match the public host; declared in both render.yaml and .env.example).
  • Passwords: minimum 8 characters, no maximum, no complexity rules.

Not present

  • Email verification (legal launch blocker)
  • Password reset
  • Rate limiting or lockout
  • MFA
  • Session revocation on suspension
  • Google sign-in (deferred to the calendar OAuth stage)

Login is case-sensitive, signup is not. Signup lowercases the email before storing it; authorize looks it up exactly as typed. A user who signs up as "Maya@x.com" cannot log in with the same casing (F08).

Access matrix

Route or actionAnonymousSeekerGuideAdmin
Public pages, login, signupYesYesYesYes
Guide profile viewAPPROVED guidesYesYesYes
Report buttonHiddenYes, not own profileYesYes
/bookings, /messagesInline promptYesYesYes
Thread viewInline promptParticipantParticipantParticipant only
/walletInline promptYesYes (guides also own a seeker profile)If seeker
/admin/**404404404Yes
bookIntro, bookPackageNoYes (self-booking not blocked)As seekerAs seeker
completeBookingNoNoOwn bookings, any guide statusNo
Cancel bookingNoOwnOwnNo
Contribute or skipNoOwnOwn, as seekerNo
Buy creditsNoYesYesNo
sendMessageNo5 free then creditsUnlimitedParticipant only
Three confirm-checkout functionsRunRunRunRun
Moderation actionsNoNoNoYes

A suspended user with a live token passes every non-admin check, because no action checks accountStatus.

Signup flows

Shared validation, in order

  1. Name not empty
  2. Email format, trimmed and lowercased
  3. Password at least 8 characters
  4. Date of birth parses and is not in the future
  5. Age attestation checkbox ticked (validated, not stored)
  6. Terms checkbox ticked
  7. Hard gate: age 18 or over from date of birth

Age uses server-local date getters on a UTC-midnight date, so it can be off by one day at timezone boundaries (inferred).

Seeker and guide

Seeker: one transaction creates User with an empty SeekerProfile and a PolicyAcceptance (terms_of_service, version 2026-08-30), then signs in. No wallet until first purchase.

Guide: also requires 1 to 5 APPROVED categories. Creates User, SeekerProfile and GuideProfile (status PENDING, pricing PAID, derived initial and a fixed avatar colour) with GuideCategory rows. Nothing else is collected, no admin is notified and no GUIDE_APPLIED metric is written.

Guide lifecycle capabilities

CapabilityStatusDetail
Approve or reject a guide applicationNot presentNew guides stay PENDING forever. Only the seed approves.
Category risk and vetting gatingSchema onlyRisk level, enhanced vetting, safeguarding badge, auto check-in and disclaimer level are never read
Badge awardingSchema onlyDisplayed from seed data; nothing awards them
Training modulesSchema onlySix modules seeded; no UI or completion action
Identity and lived-experience documentsNot presentNo upload or storage
Stripe Connect onboardingNot presentAccount fields set by seed only
Suspension and reinstatementBuiltThrough moderation enforcement
Custom category proposalsSchema onlyDeliberately deferred
Account deletionNot presentSee section 12

09Payments

Stripe Connect using separate charges and transfers, the "hold then release" model: the platform account takes the full payment through hosted Checkout and transfers the guide's 85% only when the session is completed. Everything runs in Stripe test mode. The model touches e-money rules and needs solicitor and Stripe compliance sign-off before production.

Stripe objects in use

Checkout Sessions (platform account, EUR, card), PaymentIntents (retrieve only), Transfers with source_transaction and transfer_group, Refunds. Six files call Stripe.

Not present

Webhooks, Connect account creation and onboarding links, account.updated sync, Stripe idempotency keys, Stripe.js or Elements, and any demo-mode short-circuit (IS_DEMO does not gate payments).

Where money rules are defined

RuleLocationValue
Platform feestripe.ts:17 PLATFORM_FEE_RATE0.15; guide keeps 85%
Credit packscredit-packs.ts:15 CREDIT_PACKS10 for €0.99, 25 for €1.99, 50 for €3.49
Free seeker messagescredits.ts:18 FREE_SEEKER_MESSAGES5 per seeker per thread. Duplicated as literal 5 in compose-box.tsx, wallet/page.tsx, message-actions.ts:145
Seeker refund tierscancellation.ts:5924h or more: 1; 12h: 0.5; 3h: 0.25; under 3h: 0
Package pricesSessionPackage.priceEurPer guide, per duration
Contribution windowsession-actions.ts:737 days
Contribution suggestionscontributions.ts:40Package price (or €15) times 0.5, 1 and 1.5, minimum €1
Policy copypolicy-content.tsCancellation schedule, fee disclosure, intro and PWYC notes

Paid package: book, complete, release

sequenceDiagram
  autonumber
  actor S as Seeker
  participant A as Next.js actions
  participant ST as Stripe (platform)
  participant DB as Postgres
  actor G as Guide
  S->>A: bookPackage(guide, package, slot)
  A->>ST: checkout.sessions.create (price, metadata)
  A-->>S: redirect to Stripe Checkout
  S->>ST: pays
  ST-->>S: redirect /guides/[id]?checkout_session_id=...
  S->>A: page render runs confirmPackageCheckout
  A->>DB: lookup by checkout session id (idempotency)
  A->>ST: sessions.retrieve, require paid
  A->>DB: transaction: re-check slot, create Booking CONFIRMED with amount and 15 percent fee, claim slot, metrics
  Note over A,DB: If the tab closes before this point, nothing is recorded
  G->>A: completeBooking(bookingId)
  A->>DB: transaction: COMPLETED, metrics, PWYC prompt if relevant
  A->>ST: paymentIntents.retrieve then transfers.create 85 percent to guide account
  A->>DB: store stripeTransferId
  Note over A,ST: If the transfer fails, the booking stays COMPLETED with no transfer and cannot be retried
  

Cancellation and refunds

sequenceDiagram
  autonumber
  actor U as Seeker or guide
  participant A as cancelBooking
  participant ST as Stripe
  participant DB as Postgres
  U->>A: previewCancellation then cancelBooking
  A->>A: computeRefund(hours until slot in guide timezone, canceller)
  A->>ST: refunds.create (seeker share) if above 0
  A->>ST: transfers.create (guide share) if above 0
  A->>DB: transaction: CANCELLED with refund fields and Stripe ids, free slot, metric
  Note over A,ST: No conditional claim and no idempotency key, so a double submit can refund twice
  
Who cancelsNoticeSeeker refund (€100 gross)Guide transferPlatform keeps
Seeker24h or more€85.00 (100% of guide share)€0€15
Seeker12 to 24h€42.50€42.50€15
Seeker3 to 12h€21.25€63.75€15
SeekerUnder 3h or past€0€85.00€15
GuideAny€100 (full gross)€0€0
EitherFree intro, FREE or PWYC bookingNo charge, nothing to refund

Completed bookings cannot be cancelled. There is no refund path for credits or contributions, and moderation suspensions do not cancel or refund anything.

Credits

startCreditsCheckout resolves the pack server-side, creates a platform Checkout Session with metadata {userId, packKey}, inserts a PENDING CreditPurchase and redirects. On return, confirmCreditsCheckout returns early if already COMPLETED, requires a paid session, then in one transaction claims the row with updateMany where status = PENDING, upserts the wallet increment and writes CREDITS_PURCHASED. Double-crediting is prevented; lost redirects are not recovered.

Pay-what-you-can

Completing a PWYC package creates a PROMPTED contribution with a 7-day expiry. The seeker can give €0 (recorded as MADE, no Stripe call), skip, or pay any amount above zero through Checkout. On return, confirmContributionCheckout records the amount from amount_total and immediately transfers 85% to the guide found from the URL's guide_id. The platform fee is not stored, and Booking.amountEur is never updated for PWYC bookings, so revenue reporting cannot be reconstructed from bookings alone.

Money correctness summary. No webhooks (F02), three unauthenticated confirmations (F01), transfers with no retry (F04), a possible double refund (F05), a paid-but-unbooked path with no automatic refund (F06), and no ledger: Earning and Payout are unused and the wallet is a bare counter (F30, F31). These must be resolved before real money moves.

10Messaging and sessions

Messaging works end to end for existing threads: seekers get five free messages per thread and then spend one credit per message; guides always message free. Delivery is by server action and page revalidation, with no realtime channel. Video sessions are not built.

Send path

flowchart TD
  A["ComposeBox submit"] --> B{"sendMessage: logged in and thread participant?"}
  B -- "no" --> X["Conversation not found"]
  B -- "yes" --> C{"Text 1 to 250 chars?"}
  C -- "no" --> Y["Validation error"]
  C -- "yes" --> D{"Sender is seeker?"}
  D -- "guide" --> E["guideMsgCount +1, seekerHasUnread = true"]
  D -- "seeker" --> F{"updateMany where seekerMsgCount below 5"}
  F -- "1 row" --> G["Free message used, guideHasUnread = true"]
  F -- "0 rows" --> H{"wallet.updateMany where creditBalance above 0"}
  H -- "1 row" --> I["Credit spent, count +1, guideHasUnread = true"]
  H -- "0 rows" --> Z["OutOfCreditsError, transaction rolls back"]
  E --> M["message.create"]
  G --> M
  I --> M
  M --> R["revalidatePath thread and list"]
  

One transaction at READ COMMITTED. Conditional updates provide the concurrency guarantee.

AspectStatusDetail
Starting a conversationNot presentNo code creates a Thread. Threads exist only from the seed. A seeker cannot message a new guide.
Read pathBuiltThread list ordered by last update; thread view loads all messages ascending; non-participants get 404
Unread statePartialFlags are set and cleared correctly but never displayed in the list or navigation
Out-of-credits UXBuiltBanner with wallet link, remaining allowance tally, textarea stays editable; Enter sends, Shift+Enter adds a newline
Realtime deliveryNot presentNo polling, WebSocket or SSE. Recipients see new messages only on navigation or refresh.
MetricsNot presentMESSAGE_SENT is defined but never emitted
Legal holdPartialSet by moderation; deliberately not consulted when sending; no redaction

Sessions

Today a session is a booking row. The guide clicks "Mark complete" manually; there is no check that the session time has passed and no automatic completion. The intro length (15 minutes) exists only in copy; package durations come from SessionPackage.durationMinutes.

Locked decision for video sessions. Sessions happen inside Lived on the thread page, not through an external link. Desktop shows a video panel beside the live thread; mobile uses a dedicated session route with the thread as a side panel. An upcoming-session banner (alert only, no early entry) appears in the thread for both parties from 30 minutes before. Video entry opens exactly at start time, with about 5 minutes grace after the end. Placement and routes are built first; the provider is embedded later.

Locked decision for transport. Server actions and revalidatePath are acceptable for the demo only. A real realtime transport, either WebSockets over Redis pub/sub or a managed service such as Pusher or Ably, is required before launch.

11Moderation and safety

Report intake and the admin case workflow are the most mature part of the build, with protection against duplicate reports at application and database level. Blocking, notifications, email alerts and a proper permissions model are not built.

Report intake

  • Reasons live in report_reasons: off_platform, harassment, sexual_inappropriate, hate, spam_scam, impersonation, clinical_advice, wellbeing_concern, immediate_danger (the only emergency), other.
  • Entry points: the guide profile (logged in, not own profile, thread attached if one exists) and the thread view. The modal shows reason descriptions, an emergency-services notice for immediate danger, and an optional detail field capped at 1,000 characters.
  • Validation: logged in, not self, reason active, reported user exists. The emergency flag comes from the reason row, never the client. A transcript snapshot is taken only if the reporter is a participant in the cited thread.
  • Duplicate guard: inside the transaction, an advisory lock on the reporter and reported pair, then at most one open report per reason. Reason other is capped at five open. The partial unique index backs this up, and unique violations (P2002 or SQLSTATE 23505) map to the same friendly error.
  • Emergencies: severity HIGH, legal hold on the report and cited thread, and a Notification row for every active admin. Nobody reads those rows; the only visible signal is the red banner in the admin layout.

Admin workflow

The queue at /admin/moderation sorts emergencies first, then oldest, grouped by lifecycle state, with repeat-offence banners. The case page shows both parties, reason, transcript snapshot, retention basis, legal hold, other cases against the same person and notified flags. Admins can claim, write notes, set severity, escalate, resolve (reason of at least 10 characters), mark appealed and lift enforcement.

WorkingSchema onlyNot present
Report modal with DB-sourced reasons and emergency noticeresponseDeadline never writtenBlock and unblock
Per-reason guard, advisory lock, partial unique indexAppealStatus UPHELD never setNotification inbox
Queue, case detail, claim, notes, severity, escalate, resolve, appeal, liftretentionBasis shown, never variedEmail, push or realtime emergency alerts
Emergency banner and repeat-offence flagLegal hold never cleared or sweptUser-initiated appeals
Suspension blocks login and hides the guideCategory auto check-in not wiredRBAC and audit log; per-message removal

The report success message tells users that blocking is a separate action. That action does not exist yet.

Locked decision for blocking. Either party can block; it is a full mutual cut-off and the blocked person is never told. A block cancels every non-completed booking between the pair, including a paid checkout still confirming, and refunds the seeker. Seeker-initiated blocks follow the refund matrix: more than 24 hours before start, 100% refund with no review; inside 24 hours, 50% refunded immediately and 50% held for admin review. Guide-initiated blocks refund the seeker 100% with no review. Blocking is disabled during the video window. Implementation: two DB transactions around the Stripe refund (block plus a CANCELLING state first, then refund, then finalise) so the block never depends on Stripe; a new CancellationReason field (USER_REQUEST, BLOCK) rather than extending CancelledBy; unblock is a soft delete (unblockedAt), only the blocker can unblock, and admins can see history but cannot unblock. The report modal will offer a combined report-and-block option.

Pending legal sign-off: the emergency escalation procedure (country-specific safeguarding contacts and steps) is a policy document requiring professional review. The hooks exist; the procedure does not.

12GDPR, retention and metrics

The schema has hooks for retention and deletion; none of the processes behind them exist. Metrics are written into an append-only event table that nothing reads yet.

Retention fields

FieldWritten byRead byStatus
Thread.legalHoldEmergency routing, CONTENT_REMOVED enforcementNothing (send path deliberately ignores it)Partial
Report.legalHoldCreate (emergency), escalate, resolve (emergency or permanent removal)Admin case pagePartial
Report.retentionBasisSchema default "legitimate_interest_legal_defence"Admin case pageDefault only
Report.transcriptSnapshotcreateReportAdmin case pageBuilt
User.deletionConfirmedAt, DeletionRequestNothingNothingSchema only
AccountStatus PENDING_DELETION, DELETEDNothingDELETED read in enforcement; only ACTIVE may log inSchema only
PersonalDocument.fileUrl (nullable for sweeps)NothingNothingSchema only
PolicyAcceptanceSignupNothingWrite only

Not present

  • Data export (subject access)
  • Account deletion and anonymisation
  • Retention sweeps, cron or background jobs
  • Re-acceptance when policy versions change
  • Cancellation policy acceptance record

Locked policy inputs (solicitor review pending)

  • Session transcripts: 4 weeks minimum, 12 months maximum
  • Trust and safety enforcement and vetting evidence: 6 years
  • Technical and log data: 12 months
  • Policy changes: 30 days notice, immediate for safety or legal
  • 18+ for every user, every category, every market

Metric events

EmittedWhereDefined, never emitted
BOOKING_CREATED, PACKAGE_BOOKEDbooking-actions.tsGUIDE_APPLIED
SESSION_COMPLETED, INTRO_COMPLETED, CONTRIBUTION_PROMPTEDsession-actions.tsGUIDE_APPROVED
BOOKING_CANCELLED (amount is the refund)cancellation-actions.tsGUIDE_ACTIVATED_AFTER_TRAINING
CONTRIBUTION_MADE, CONTRIBUTION_SKIPPEDcontribution-actions.tsBADGE_EARNED
CREDITS_PURCHASEDcredit-actions.tsFIRST_PAID_SESSION, REPEAT_PAID_SESSION
GUIDE_SUSPENDED, GUIDE_REINSTATEDmoderation-actions.tsREVIEW_SUBMITTED, MESSAGE_SENT

Events are written inside the same transaction as the change they describe, which keeps them consistent. There are no consumers: no dashboards, exports or aggregate queries.

13UI and design system

Tailwind v4 with design tokens lifted from the HTML prototype into an @theme block in globals.css. Inter for text, Playfair Display for headings, loaded through next/font. No component library, icon set or dark mode.

bg #faf8f5
surface #ffffff
border #e8ddd0
forest #2e3a33
text-muted #5c6b5e
text-faint #9aa89c
sage #4a6b52
terracotta #e07a5f
terracotta-wash #f5e8e4
terracotta-dark #8b3a28
dusty-blue #cfe7e6
sand #f0eae0
gold #c9a84c

Radius: --radius-card 16px, --radius-sm 10px; two card shadows. Breakpoints are Tailwind defaults (the nav comment assumes the prototype's 720px, but Tailwind's md is 768px). A few colours are hard-coded outside the tokens in the footer, home page and avatar fallback.

Components

ComponentTypePurpose
site-header, site-footerServerHeader loads session, categories, admin and seeker flags; static footer
site-navClientDesktop and mobile nav, categories dropdown, logout
discover/guide-card, badge-chip, category-pillServerGuide summary card, static badge label, category pills with "+N" overflow
discover/discover-clientClientIn-memory filtering by category, language, text
badge-popoverClientAccessible tap, hover and focus explanation of each badge
booking/booking-panelClientIntro or package mode, slot picker, fee disclosure
bookings/cancel-button, complete-button, contribution-promptClientCancel with refund preview, guide completion, PWYC amount picker
messages/compose-boxClient250-character limit, allowance tally, out-of-credits banner
report/report-buttonClientReport modal
admin/case-controls, admin/resolve-formClientModeration controls and resolution
login-form, signup/*-signup-form, wallet/buy-creditsClientAuth forms and credit pack purchase

State and forms: local React state only (useState, useTransition, useActionState), router.refresh() after mutations, no context providers or data-fetching libraries. Validation is native HTML attributes on the client and hand-written checks on the server; there is no schema library. Small helpers such as fmt() are duplicated across page files.

14Integrations

ServiceStatusClient and envIf unavailable
PostgresBuiltdb.ts, DATABASE_URLEvery page fails: the header queries the DB on every request
Auth.jsBuiltauth/auth.ts, AUTH_SECRET, AUTH_URLMissing secret breaks auth(), which the header calls, so every page fails
StripePartialstripe.ts, STRIPE_SECRET_KEY. Publishable key and webhook secret declared, unused.Module throws at import, taking down /guides/[id], /bookings, /wallet
SentryBuiltinstrumentation*.ts, next.config.ts; DSNs, org, auth tokenSilent no-op
ZeptoMailNot presentEnv names only; from-address set in render.yamln/a
Google Calendar, Microsoft GraphNot presentEnv names only; CalendarConnection unusedn/a
VideoNot presentNo SDK, routes or constantsn/a
File storageNot presentNo SDK or upload endpointn/a
Currency and FXNot presentEUR hard-coded in six Stripe call sitesn/a
TimezonesPartialtimezone.ts: IE, UK, NL mapped, everything else Europe/Berlin; no DST handling; no viewer conversionn/a
Google FontsBuiltnext/font/google, fetched at build (inferred)Build-time only

15Security posture

The fundamentals are sound: React escapes output, the one raw SQL statement is parameterised, secrets stay out of the repo, and admin routes are checked against the database. The gaps are in the perimeter (headers, rate limits, middleware) and in a handful of unauthenticated or under-validated entry points.

ControlStatusDetail
Output escapingOKReact only; no dangerouslySetInnerHTML. One DB value flows into an inline style (avatarColor).
SQL injectionOKOnly a tagged-template $executeRaw; no unsafe raw calls
CSRFFramework defaultServer actions rely on Next's Origin and Host check; Auth.js uses its own token. allowedOrigins not set.
SecretsOK.env* ignored except the example; Render secrets are sync: false. A shared demo password and a Stripe test account id are committed in the seed (test data).
Rate limitingNot presentLogin, signup, Checkout creation, messaging and reporting are unthrottled
Security headersNot presentNo CSP, HSTS, X-Frame-Options or Referrer-Policy
MiddlewareNot presentNo central auth or header enforcement
Authorisation on mutationsMostly21 of 24 actions check the caller correctly. The three confirm-checkout functions do not. No non-admin action checks accountStatus.
Input validationGapsNo maximum on contribution amounts, investigation notes or resolution text; booking dates passed straight to new Date(); sendMessage trims without a type check
Redirect URLsRiskStripe success and cancel URLs are built from the request Host header
Error leakageRiskBooking actions return raw error.message to the client
Token storageSchema onlyCalendar OAuth token columns are plaintext; the schema requires encryption before use
Admin modelStopgapSingle isAdmin boolean; no roles, least privilege or audit log. Admins can see both parties' emails.

16Performance and data access

Fine at demo scale. Nothing is cached, nothing is paginated, and several read paths load whole tables into memory. These are the first things to break as usage grows.

AreaCurrent behaviourScaling risk
RenderingEvery route force-dynamic, including static pagesNo CDN or full-route caching; every hit reaches Node and Postgres
Header3 DB queries and 3 auth() calls per request, no memoisationFixed tax on every page view
Discover and homeLoads every eligible guide with all reviews and completed bookings, aggregates in JSGrows with total reviews and bookings, not with guides shown
Moderation queueLoads all reports; 2 count queries per reported userN+1 fan-out
MessagingThread view loads every message; list loads every threadLong threads and active users
Bookings dashboardAll bookings for both roles with includesLinear in history
AvailabilityLoads every unbooked slot including past ones, filters in JSPast slots accumulate forever
ConnectionsOne pg pool with defaults (max 10, inferred); no poolerMultiple instances will exhaust basic-plan connection limits
RevalidationrevalidatePath per mutation; no tagsAcceptable; move to tags when caching is introduced

17Errors and observability

In place

  • Sentry on server, edge and browser; request errors captured through onRequestError
  • Traces at 100% sampling
  • Actions return { ok, error } objects rendered inline
  • Known failures mapped: unique violations, out of credits, invalid credentials

Missing

  • error.tsx, global-error.tsx, not-found.tsx, loading.tsx
  • Structured logging (no console calls in src)
  • Health check route and Render health check path
  • User context, PII scrubbing (beforeSend), Replay
  • Try/catch around Stripe calls; one throw after commit in completeBooking
  • Alerting on payment or moderation failures

18Deploy, environments and operations

Infrastructure is declared as code in render.yaml. A push to main builds and deploys automatically; migrations run before the new version starts. There is one environment: the demo/staging service. Environment variable changes alone do not trigger a deploy; a manual redeploy is required.

flowchart LR
  DEV["Local: next dev + prisma dev
or Docker Postgres 16"] -->|"git push main"| GH["GitHub
THELIVEDAPP-LTD/lived-app"] GH -->|"auto-deploy on commit"| B["Render build
npm install (postinstall: prisma generate)
next build"] B --> PD["preDeploy
prisma migrate deploy"] PD --> RUN["next start
lived-app, starter plan"] RUN --> DB[("lived-db
Postgres 16")] SH["Render Shell
npm run db:seed (manual)"] -.-> DB

Declared in render.yaml

  • Database lived-db: Frankfurt, basic-256mb, Postgres 16
  • Web service lived-app: Node, Frankfurt, starter
  • Build npm install && npm run build, start npm run start, pre-deploy npm run db:migrate:deploy
  • DATABASE_URL from the database; IS_DEMO, AUTH_URL and ZEPTOMAIL_FROM_ADDRESS as literals; all secrets sync: false

Not declared

  • Health check path, instance count, autoscaling
  • Branch, autoDeploy, NODE_VERSION, env groups
  • IP allow-list on the database, backups or PITR
  • Workers, cron jobs, Key Value, disks
  • A production service (deliberately deferred)

Environment variables

VariableLocal .envrender.yamlRead by code
DATABASE_URLYesFrom databasedb.ts, seed, Prisma config
IS_DEMOYesLiteral "true"config.ts (banner, login hint), seed
AUTH_SECRETYesSecretAuth.js implicitly
AUTH_URLNoLiteral public URLAuth.js implicitly. Update at domain cutover in both render.yaml and .env.example.
STRIPE_SECRET_KEYYesSecretstripe.ts
STRIPE_PUBLISHABLE_KEY, STRIPE_WEBHOOK_SECRETNoSecretNot referenced
ZEPTOMAIL_API_TOKEN, ZEPTOMAIL_FROM_ADDRESSNoSecret, literalNot referenced
SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN, SENTRY_ORG, SENTRY_AUTH_TOKENNoSecretInstrumentation, next.config, build plugin
GOOGLE_CALENDAR_CLIENT_ID/SECRET, MICROSOFT_GRAPH_CLIENT_ID/SECRETNoSecretNot referenced

Seed data

Always seeded (every environment, idempotent upserts): 23 categories (16 standard, 6 sensitive of which 3 auto check-in, 1 high-risk "Domestic Abuse & Coercive Control"), 153 subcategories, 6 training modules and 10 report reasons.

Only when IS_DEMO=true: 10 demo users sharing one password, 5 APPROVED guides with safety training and badges (Maya paid, James pay-what-you-can, Aisha free, plus Victor and Wes), 2 seekers, an admin, 7 session packages, 45 availability slots relative to the seed date, a wallet, one completed intro with a review, 2 threads (one is the out-of-credits fixture), and 8 moderation cases across all states.

Demo logins (staging only)

AccountRoleUse it to test
maya@demo.thelivedapp.comGuide, paid, ID verifiedGuide side of bookings, completion and transfers
sam@demo.thelivedapp.comSeeker with creditsBooking, messaging, cancellation
tess@demo.thelivedapp.comSeeker with no creditsOut-of-credits state
admin@demo.thelivedapp.comAdminModeration queue and case actions

All demo accounts share the password demo-password. Other seeded users (James, Aisha, Victor, Wes, Rob, Nadia) use the same password; check prisma/seed.ts for their exact addresses. Stripe connected-account details in the seed are deliberately left out of this document.

The seed's only guard is the IS_DEMO string; nothing checks which database it points at. Re-running it resets Victor's and Wes's guide status to APPROVED and adds new slots. The demo seeker fixture has drifted and needs a deliberate reset before demos.

Operational references

Render web service srv-da72k1e7bikc73emf3h0, database dpg-da72jmu7bikc73eme1n0-a, Blueprint exs-da72btvavr4c738941g0. Domains thelivedapp.com and the-lived-app.com are on Cloudflare; the app still serves from lived-app.onrender.com.

19Testing and quality

There are no automated tests and no CI. Quality so far rests on strict TypeScript, ESLint, and manual live verification recorded in docs/verification.

What exists

  • npm run lint; tsc run ad hoc
  • Report-guard rig: runs the real createReport from Node with stubbed auth and cache, scenarios single, dup, race, clean. Prints results; no assertions. The race scenario needs real Postgres, not PGlite.
  • Verification records for payments and the refund matrix, credit purchases, and the out-of-credits UI

Untested critical paths

  • Login, suspension block, admin gate
  • Signup validation and 18+ gate
  • Booking races, intro rule, checkout idempotency
  • Completion transfer, cancellation and refund matrix
  • Contributions, credits, free-message claim
  • Moderation enforcement, lift, emergency routing
  • Timezone maths, seed idempotency, migrations (including the unmanaged index)

20Git state

Remote github.com/THELIVEDAPP-LTD/lived-app, branch main in sync with origin, clean working tree, no stashes. 35 commits from 26 Aug to 10 Sep 2026. Latest: d5ae62e "fix(report): re-add the advisory lock via $executeRaw, verified locally first".

Last 30 commits
HashDateMessage
d5ae62e2026-09-10fix(report): re-add the advisory lock via $executeRaw, verified locally first
2937bd12026-09-10fix(report): revert the advisory lock, restoring report intake
7b8f8642026-09-07fix(report): close the duplicate-guard race with an index and an advisory lock
b3f80aa2026-09-06fix(report): scope the duplicate-report guard to the reason category
8c89eb52026-09-06fix(report): constrain modal height and stop backdrop click discarding detail
7027f4f2026-09-01docs(credits): live verification record for the 2c out-of-credits UI
a5fc8692026-09-01feat(messages): Enter to send, and promote the allowance tally
fa21ed52026-09-01feat(messages): remaining-allowance tally and guide-side note
2b0779a2026-08-31copy(credits): one blocked message at a time, single error variant
7a3f4b72026-08-31copy(credits): reframe messaging limit as intent, not restriction (2c)
62cdd482026-08-31feat(credits): out-of-credits block UI in thread view (2c)
636d2812026-08-31docs(credits): live verification record for 2b credit purchases
4bbfea62026-08-31feat(credits): wallet + buy-credits page (2b, acquire side)
e70f00e2026-08-30feat(messages): send/compose write path, seeker 5-free-then-credit, atomic free-slot claim
3efcdb22026-08-30Guide multi-category + self-service signup
2dfdd482026-08-30Document AUTH_URL in render.yaml and .env.example
25b11762026-08-29Add report intake UI (user-facing "Report" trigger)
4c7a45a2026-08-29Add moderation admin interface (spec §9a)
6e2cca42026-08-29Drop the @AGENTS.md import from CLAUDE.md
6d503412026-08-29Add CLAUDE.md project working rules
24e070a2026-08-29Record payments verification: hold-then-release + full refund matrix
d18d6fe2026-08-27Switch to hold-then-release payments; add graduated cancellation/refunds
2eb04372026-08-27Use the real logo in the header, not the invented circle+text mark
db3a2b82026-08-26Fix favicon, add full-catalog category browsing, mobile hamburger nav
84a94532026-08-26Use the real Lived logo as the hero's central brand element
6927b6c2026-08-26Build the real marketing home page
d5c16ae2026-08-26Resolve IS_DEMO drift: render.yaml now matches the live service
be2ae502026-08-26Fix login on Render: trust the proxy host in NextAuth config
2232a402026-08-26Add §8 pay-what-you-can contribution flow with real Stripe Checkout
37ae51c2026-08-26Wire paid bookings to real Stripe Connect direct charges (test mode)

21Prototype vs production

The single-file HTML prototype (about 6,500 lines, outside the repo) is the functional reference. Production covers discovery, profiles, booking, payments, messaging, reporting and moderation. The list below is what the prototype has and production does not; the mapping from prototype function names is inferred.

CapabilityPrototype evidencePriority for launch
Start a conversationstartThread, getOrCreateThreadRequired
Guide application form (story, location, rate, pronouns)submitGuideAppRequired
Admin guide approval, rejection, roster, suspend and reinstateAdmin tabsRequired
Guide portal: profile, pricing, availability diary, packages, training, documents, earnings, payouts, bank details, notifications, settings, appealsrenderGuidePortal, gp-*Required
Seeker portal: profile, settings, wallet history, notificationsrenderSeekerPortal, sp-*Required
BlockingsubmitReportBlockRequired
ReviewssubmitReviewRequired
In-app notifications and unread badgesrenderUserNotifications, updateMsgBadgeRequired
Account deletion and data exportsubmitDeletionRequest, requestDataExportRequired (GDPR)
Calendar syncMock buttonsRequired (locked decision)
ReschedulingproposeRescheduleShould have
Viewer-timezone slot conversionconvertSlotToViewerTimezoneShould have
AI matching and crisis or regulated-advice triage on DiscoverrunAIMatch, detectTriageFlagShould have (triage is a safety feature)
Admin analytics: marketplace health, revenue, investor stats, category and deletion requestsAdmin tabsShould have
Multi-currency displayconvertCurrency with a static rate tableLater; needs a live FX source
Featured-guide selection, role switchinggetFeaturedReason, switchToPortalLater

22Findings register

Every defect, risk and gap from the audit, ranked. Critical items can lose or misdirect money, or let a sanctioned user keep acting. High items block launch or break a core journey. Medium items are hardening and scale work.

IDSeverityAreaFindingEvidenceRemedy
F01CriticalPaymentsThe three Checkout confirmations run during page render with no caller check. The contribution confirmation takes the transfer destination from the guide_id URL parameter, so a crafted URL can send the 85% share to a different connected account.contribution-actions.ts:151-233; booking-actions.ts:251; credit-actions.ts:107Derive every party from the database record or Stripe metadata, never the URL. Move fulfilment to webhooks; keep the return page as an authenticated, idempotent fast path.
F02CriticalPaymentsNo Stripe webhooks. If a payer closes the tab before the redirect, the credits, booking or contribution is never recorded. Pending credit purchases stay PENDING forever.No webhook route; CLAUDE.md launch blockerSigned /api/webhooks/stripe handling checkout.session.completed, checkout.session.expired, charge.refunded, account.updated, with an event table for idempotency and a reconciliation job.
F03CriticalAuthSuspension does not end existing sessions. JWTs carry only the user id and callbacks never re-read status, and no non-admin action checks accountStatus.auth/auth.ts; moderation-actions.ts:377 comment is wrongAdd a sessionVersion on User checked in the session callback (short cache), bump it on suspension, and add a shared requireActiveUser() guard to every action.
F04CriticalPaymentsGuide transfer runs after the booking is committed as COMPLETED. If Stripe fails, the guide is never paid and the status guard blocks any retry. The same applies to contribution transfers.session-actions.ts:105-131; contribution-actions.ts:213-233Record a transfer state (PENDING, SENT, FAILED), use Stripe idempotency keys derived from the booking id, and retry from a job.
F05CriticalPaymentsA double-submitted cancellation can issue two refunds: Stripe calls happen before any conditional claim, with no idempotency key.cancellation-actions.ts:90-179Claim first with updateMany to a CANCELLING state, then call Stripe with an idempotency key, then finalise. This is the locked block-cancel design; apply it to all cancellations.
F06CriticalPaymentsIf the slot or package disappears between payment and confirmation, the seeker is charged, no booking is created and they are told to contact support. No automatic refund.booking-actions.ts:245-249, 287-294Hold the slot at Checkout creation (pending booking with expiry), or refund automatically in the fulfilment handler.
F07HighTrustGuides who are APPROVED but not safety-trained are hidden from Discover yet bookable through a direct link. Booking also ignores the guide's category vetting requirements.guides.ts:34-39 vs guides.ts:96, booking-actions.ts:56, 118One isBookable(guide) policy used by Discover, profile, booking and confirmation, including category risk rules.
F08HighAuthSignup stores lowercased emails but login looks them up as typed, so mixed-case logins fail.auth/auth.ts:24-40; signup-actions.ts:30Normalise in authorize; consider a citext column or a lowercase unique index.
F09HighSafetyLifting enforcement sets the guide to APPROVED even if they were PENDING before suspension, and writes nothing to the report.moderation-actions.ts:563-597Store the pre-enforcement status and restore it; record the lift on the case and in an audit log.
F10HighSafetyAppeals always resolve as REJECTED, even when the outcome changes. UPHELD is never written. Users cannot appeal themselves.moderation-actions.ts:502Explicit appeal decision in the resolve form; user-initiated appeal entry point.
F11HighSupplyThere is no path from PENDING to APPROVED. Every real guide who signs up is stuck.Section 08Admin application review with approve, reject and reasons, feeding the onboarding pipeline in section 23.
F12HighMessagingNo code creates threads, so a seeker cannot start a conversation with a guide.Only seed calls thread.upsert"Message this guide" entry point with upsert on the unique (seeker, guide) pair and block checks.
F13HighSecurityNo rate limiting, no security headers, no middleware; Stripe return URLs built from the Host header.Section 15Cloudflare rules plus app-level limits in Redis; headers in next.config or middleware; a fixed APP_URL for redirects.
F14HighBookingsBooking actions do not check the slot is in the future, allow self-booking, skip accountStatus, and accept unvalidated dates. One intro per pair is not enforced by the database.booking-actions.ts:41-106Validate inputs with a schema library; add a partial unique index on intros; share the isBookable and active-user guards.
F15HighResilienceThe Stripe module throws at import when the key is missing, taking down three pages that do not all need Stripe to render.stripe.ts:11-15Lazy client getter; fail the action, not the page.
F16HighData protectionCalendar OAuth tokens would be stored in plaintext columns.schema.prisma:960-966Application-level envelope encryption with a key held outside the database before calendar work starts.
F17HighSecurityBooking actions return raw exception messages to the browser.booking-actions.ts:104, 231, 345Map to user-facing messages; send detail to Sentry.
F18HighPerformanceUnbounded queries everywhere; Discover loads every review and completed booking for every guide.Section 16Pagination, a guide stats table updated on write, tag-based caching.
F20HighQualityNo automated tests and no CI on a codebase that moves money.Section 19Unit tests for pure maths, integration tests against Postgres 16 and stripe-mock, CI on every pull request.
F24HighSafetyEmergency reports create notifications nobody reads. The only signal is a banner that someone must happen to see.moderation-actions.ts:38-60Email and push alerts to on-call moderators, acknowledgement tracking and response deadlines.
F30HighFinanceNo money ledger. Earning and Payout are unused, contribution fees are not stored, PWYC bookings carry no amount. Revenue and payouts cannot be reconciled from the database.Sections 05, 09Double-entry style ledger entries written in the same transaction as each money movement.
F19MediumPerformanceHeader costs 3 queries and 3 session reads per request.site-header.tsxMemoise with React cache(); put role flags in the token.
F21MediumOpsNode version unpinned.package.json, render.yamlengines, .nvmrc and NODE_VERSION.
F22MediumDataPartial unique index is invisible to Prisma and can be dropped by a future migration.Migration 9CI check with prisma migrate diff that fails if the index is missing.
F23MediumObservabilitySentry traces at 100%, no PII scrubbing, no error boundaries.Section 17Lower sample rate, beforeSend scrubbing, global-error.tsx.
F25MediumAnalyticsEight metric types never emitted; nothing reads metrics.Section 12Emit remaining events; nightly rollups into reporting tables.
F26MediumTimeThree-country timezone map, default Berlin, no DST handling, no viewer conversion.timezone.tsStore slots as UTC instants plus an IANA zone on the guide; convert for viewers.
F27MediumDocsREADME and .env.example reference a missing lib/payments.ts; IS_DEMO does not gate payments; spec and prototype live outside the repo; several comments contradict code.Sections 04, 13Documentation pass; bring spec into docs/.
F28MediumOpsSeed only guarded by IS_DEMO; re-running changes guide status.prisma/seed.tsRefuse to run demo seed unless the database is on an allow-list.
F29MediumDataMany models unused and some fields misleading (direct-charge comment, lastActiveRole).Section 05Keep models the roadmap needs; remove or document the rest.
F31MediumFinanceWallet is a counter; individual credit spends are not recorded.message-actions.tsWallet transactions table; balance derived or reconciled from it.
F32MediumPaymentsContribution amount has no upper bound or check against Stripe's minimum charge.contribution-actions.ts:54Bounds and a friendly minimum.

23Target production architecture

The target keeps what works: one Next.js codebase, Prisma on Postgres, Render in Frankfurt, Stripe Connect with hold-then-release. It adds the pieces a money-moving, safety-critical marketplace needs: an edge, webhooks, a worker with scheduled jobs, a shared cache and queue, realtime, file storage, and separate environments. Items marked "locked" come from the founder's decision log; everything else is proposed.

flowchart TB
  U["Seekers, guides, moderators"] --> CF["Cloudflare
DNS, TLS, WAF, bot and rate rules, CDN"] CF --> WEB subgraph RENDER["Render, Frankfurt (production)"] WEB["Web service, 2+ instances
Next.js pages, server actions,
webhook, health and realtime-auth routes"] WK["Background worker
outbox and job consumer"] CRON["Cron jobs
sweeps, reconciliation, rollups"] KV[("Key Value (Redis)
rate limits, queue, pub/sub, cache")] POOL["Connection pooler"] PG[("Postgres primary
point-in-time recovery")] RR[("Read replica
admin and analytics")] end WEB --> KV WK --> KV WEB --> POOL WK --> POOL CRON --> POOL POOL --> PG PG -.-> RR ST["Stripe Connect"] -->|"signed webhooks"| WEB WEB --> ST WK --> ST WK --> ZM["ZeptoMail"] WK --> CAL["Google Calendar,
Microsoft Graph"] WEB --> OBJ["EU object storage
documents and exports, encrypted"] WEB --> RT["Realtime transport"] WEB --> VID["Video provider"] WEB --> OBS["Sentry EU, log drain, uptime"] WK --> OBS

Target production topology. A staging copy mirrors it at smaller sizes with Stripe in test mode and demo seed allowed.

Environments

EnvironmentPurposeDataStripeDeploys from
LocalDevelopmentDocker Postgres 16 (not PGlite, which hides concurrency bugs); demo seedstripe-mock, then a verification-only sandbox with a restricted key (locked)Working copy
CITests and checksEphemeral Postgres 16 servicestripe-mockEvery pull request
StagingDemo and pre-release verification (today's lived-app)Own database, demo seed, IS_DEMO=trueTest modemain
ProductionReal users on thelivedapp.comOwn database, reference seed only, PITR, IP allow-listLive, after compliance sign-offTagged release or promoted build

Use Render environment groups so secrets are managed once per environment, and set AUTH_URL and a new APP_URL per environment.

Application tier

  • Stateless web instances. JWT sessions and no in-memory state already make horizontal scaling straightforward, once rate limits and pub/sub move to Redis.
  • Middleware for security headers, authenticated-area redirects and request ids.
  • A shared guard module: requireUser, requireActiveUser, requireSeeker, requireGuide, requirePermission, and one isBookable policy. Every action starts with one of them.
  • Schema-validated inputs for every action (a single validation library, shared between client and server).
  • Route handlers only where server actions do not fit: /api/webhooks/stripe, /api/webhooks/calendar, /api/health, realtime token issuing, data export download.
  • Transactional outbox: any side effect (email, notification, transfer, calendar write, metric rollup) is recorded as a job row in the same transaction as the state change, then executed by the worker. This removes every "Stripe after commit with no retry" path.

Background jobs

JobTriggerWhat it doesIdempotency
Stripe event processorWebhook enqueueFulfils credits, bookings and contributions; syncs account.updated; records refunds and disputesStripe event id primary key
Checkout reconciliationEvery 15 minutesRetrieves sessions for pending purchases and holds; fulfils or expires themConditional status claim
Transfer sender and retryOutbox, plus sweepSends guide transfers for completed sessions and contributions; retries failures with backoffStripe idempotency key from booking or contribution id
Session lifecycleEvery 5 minutesOpens and closes video windows, auto-completes or flags sessions after end plus grace (rule to be decided)Conditional status claim
Contribution expiryHourlyPROMPTED to EXPIRED after 7 daysupdateMany by expiry
Notifications and emailOutboxIn-app notifications, ZeptoMail sends, emergency alerts to on-call moderatorsJob id
Retention sweepDailyDeletes or redacts transcripts, documents and logs past their period unless under legal holdDeterministic by date
Deletion processorDailyExecutes deletion requests: anonymise user, keep what the retention register requiresRequest status
Data export builderOn requestAssembles a subject-access export to object storage with an expiring linkRequest id
Calendar syncProvider webhooks and pollingImports guide busy time; writes confirmed bookings to both calendarsExternal event ids
Housekeeping and rollupsNightlyPrunes past unbooked slots, recomputes guide stats, rolls up metricsRecompute

Payments, target flow

sequenceDiagram
  autonumber
  actor S as Seeker
  participant W as Web
  participant ST as Stripe
  participant DB as Postgres
  participant Q as Worker
  S->>W: bookPackage
  W->>DB: create Booking PENDING_PAYMENT holding the slot, expires in 30 min
  W->>ST: checkout.sessions.create with idempotency key
  S->>ST: pays
  ST->>W: POST /api/webhooks/stripe checkout.session.completed
  W->>W: verify signature
  W->>DB: insert StripeEvent (id unique) and outbox job in one transaction
  W-->>ST: 200
  Q->>DB: claim job, Booking PENDING_PAYMENT to CONFIRMED, ledger entries
  ST-->>S: redirect to return page
  S->>W: return page shows status from DB (authenticated)
  
  • Release: when the session completes, an outbox job sends the 85% transfer with an idempotency key and writes the ledger.
  • Connect onboarding: create connected accounts and onboarding links from the guide portal; charges_enabled and payouts_enabled come only from account.updated.
  • Ledger: one row per money movement (charge, platform fee, transfer, refund, contribution, credit purchase, credit spend, payout), written in the same transaction as the state change. The existing Earning and Payout models can be the starting point.
  • Payout schedule: monthly as standard with an optional fortnightly early payout that carries a 20% platform fee instead of 15% (locked policy, from the legal documents).
  • Block-driven cancellation: two transactions around the Stripe refund with a CANCELLING state and a CancellationReason field (locked).
  • Compliance gate: live mode only after solicitor and Stripe confirm the hold-then-release model does not require an e-money licence (locked).

Identity and access

  • Email verification before booking or messaging, and password reset, both via ZeptoMail with single-use hashed tokens.
  • Normalised emails and a case-insensitive unique index.
  • Session revocation through a sessionVersion checked in the session callback.
  • Rate limits on login, signup, reset, checkout creation, messaging and reporting, stored in Redis.
  • Staff roles replacing isAdmin: for example support, moderator, senior moderator and administrator, each with explicit permissions; multi-moderator assignment; an append-only audit log of every staff action, including every view of a transcript or personal document.
  • Google sign-in later, from the same Google Cloud project as calendar OAuth (locked sequencing).

Guide onboarding pipeline

flowchart LR
  A["Application
story, location, languages,
categories, pricing model"] --> B{"Staff review"} B -- "reject with reason" --> R["REJECTED"] B -- "approve" --> C["Required training
safety basics, crisis awareness,
boundaries"] C --> D["SAFETY_TRAINED badge
awarded automatically"] D --> E{"Category risk rules"} E -- "enhanced vetting or
high-risk category" --> F["ID verification and
advanced safeguarding"] E -- "standard" --> G F --> G["Stripe Connect onboarding
(paid and PWYC guides)"] G --> H["Availability, packages,
calendar sync"] H --> I["Bookable
single isBookable policy"]

Badges stay independent, not a ladder (locked). Lived-experience evidence and ID documents go to encrypted EU object storage, are reviewed by staff with every access logged, and are swept after verification according to the retention register.

Messaging, notifications and sessions

  • Thread creation from the guide profile and after booking, respecting blocks.
  • Realtime delivery of messages, unread counts and session state (locked requirement; provider open).
  • Notification centre reading the existing Notification table, with email fallback and user preferences.
  • Video sessions inside the thread page with the locked timing rules; a SessionRoom record per booking storing provider room id, join times and end time for disputes.
  • Reviews after completion, emotion-led language with hidden numeric weights (locked).

Data protection

  • Retention register implemented as code: each data class has a period, a legal basis and a sweep. Legal hold overrides sweeps.
  • Subject access export and account deletion pipelines using the existing DeletionRequest model and account states.
  • Encryption: provider-managed at rest for the database and storage, plus application-level encryption for OAuth tokens and document keys.
  • Processor register kept alongside the retention register, with EU residency for every new vendor.
  • Policy re-acceptance on version change, reading PolicyAcceptance.

Operations and quality

  • /api/health checking the database and Redis, wired to Render's health check and an external uptime monitor.
  • Structured JSON logs with request ids, shipped to a log drain; Sentry with lower trace sampling, PII scrubbing and user ids.
  • Alerts on webhook failures, job retries exhausted, emergency reports unacknowledged, and error-rate spikes.
  • Point-in-time recovery on production, a tested restore runbook, and the database IP allow-list set.
  • CI on every pull request: lint, type-check, unit tests, integration tests against Postgres 16 and stripe-mock, a migration drift check that protects the partial index, and a Playwright smoke suite for sign-up, booking, payment, messaging and reporting.

Proposed schema additions

AdditionPurposeSource
StripeEventWebhook idempotency and auditProposed
Job (outbox)Reliable side effects with retriesProposed
LedgerEntry (or activate Earning, Payout)Reconcilable money movementsProposed
WalletTransactionCredit purchases and spendsProposed
Block with unblockedAt; Booking.cancellationReason; CANCELLING statusBlocking and safe cancellationLocked
PENDING_PAYMENT booking status with hold expiryPrevent paid-but-unbookedProposed
Transfer state on Booking and Contribution; Contribution.platformFeeEurRetryable, reportable transfersProposed
Partial unique index on one active intro per pairEnforce the intro rule in the databaseProposed
VerificationToken, PasswordResetToken, User.sessionVersionEmail verification, reset, revocationProposed
StaffRole and AuditLogRBAC and immutable staff audit trailLocked requirement, proposed shape
Guide application fields and review outcomeApplication and approval workflowProposed
SessionRoomVideo session recordProposed
GuideStatsMaterialised rating and session countProposed
Slot start as UTC instant plus guide IANA timezoneCorrect time maths across DST and countriesProposed
NotificationPreferencePer-channel opt-inProposed

24Scaling path

Lived's load profile is modest per user (a handful of bookings and messages per week) but spiky around evenings and session start times. Correctness matters far more than throughput at launch. Scale in stages, triggered by measurements rather than dates.

StageIndicative sizeInfrastructureApplication changes
1. LaunchUp to about 1,000 monthly active usersTwo web instances, one worker, cron, small Redis, production Postgres with PITR and a poolerEverything in phases 0 to 2 of the roadmap. Pagination everywhere. Header memoised.
2. GrowthAbout 10,000Autoscaled web, more worker concurrency, larger database tier, read replicaGuideStats table; tag-based caching of Discover and public profiles; admin and analytics on the replica; metric rollups
3. ScaleAbout 100,000Web and worker scaled independently; managed realtime at higher tier; CDN caching of public pagesPostgres full-text search or a dedicated search service for matching; monthly partitioning of metric_events and messages; analytics exported to a warehouse; archive old threads

Signals to watch

  • p95 latency of Discover and thread pages; database CPU and connection count; pool wait time.
  • Webhook processing lag and job queue depth.
  • Realtime concurrent connections around session start times.
  • Moderation queue age, especially emergency acknowledgement time.

Multi-region is not needed: users and data residency are EU. Keep a single Frankfurt region and invest in backups and recovery time instead.

25Roadmap to production

Phases are ordered by dependency. Each phase ends with live verification on the staging environment, which is the project's acceptance standard.

PhaseGoalScopeFindings closed
0. Correctness and securityNothing can lose, misdirect or double-move money; sanctions take effect immediatelyAuthenticate and re-derive parties in all confirmations; claim-then-call pattern for cancellations; transfer state and retries; slot hold or auto-refund; session revocation and active-user guard; email normalisation; single bookable policy; lift and appeal fixes; input validation; lazy Stripe client; error mapping; pin NodeF01, F03 to F10, F14, F15, F17, F21
1. Complete the core loopA real guide can apply, be approved and get booked; a seeker can find, message, book, meet and reviewGuide application and staff review; training and badge awarding; Connect onboarding; guide portal (profile, availability, packages, earnings); seeker account area; thread creation; reviews; blocking (locked design); notification centre; ZeptoMail with email verification and password resetF11, F12, F24 (part)
2. Production infrastructureThe platform runs itself reliablySeparate production service and database; Cloudflare cutover; webhooks, outbox, worker and cron; Redis rate limiting; security headers and middleware; realtime; video provider and session routes; calendar OAuth with encrypted tokens; ledger; RBAC and audit log; GDPR export, deletion and sweeps; health checks, logging, alerting, PITR; tests and CI; pagination and cachingF02, F13, F16, F18 to F20, F22, F23, F25, F26, F28, F30, F31
3. Launch gatesLegal and operational readinessSolicitor sign-off on the retention register and the emergency-escalation procedure; solicitor and Stripe confirmation of hold-then-release; email verification live; switch Stripe to live mode; penetration test; load test around session start; moderator on-call rota and runbooks; demo data removed from productionLaunch blockers in CLAUDE.md
4. ScaleGrow with measured demandStages 2 and 3 of the scaling path; AI matching and safety triage; admin analytics; multi-currency display with a live FX source; rescheduling; viewer timezonesF26 (full), remaining gaps

26Decisions and open questions

Locked decisions the build must honour

AreaDecisionBuilt?
PositioningPeer support only; never therapy, counselling or professional advice. Keep a hard boundary with clinical care in data and copy.Yes in copy and report reasons
StackNext.js App Router with TypeScript, Prisma 7 and Postgres, Auth.js v5, Stripe Connect separate charges and transfers, Tailwind v4, Render FrankfurtYes
Fees15% platform fee on paid sessions; guide share released on completionYes
Messaging5 free seeker messages per thread, then 1 credit each; guides free. Packs 10 for €0.99, 25 for €1.99, 50 for €3.49Yes
BookingMandatory free 15-minute intro before paid sessions, except fully free guidesPartial not DB-enforced
RefundsSeeker: 24h or more 100% of guide share, 12 to 24h 50%, 3 to 12h 25%, under 3h 0%. Guide cancels: 100% of grossYes
TrustFour independent badges with tap or hover explanationsDisplay only
Categories1 to 5 per guide; three pills plus "+N" on cardsYes
Age18+ for everyone, everywhereYes
Reports and blocksPer-reason report guard with "other" exempt; block design as described in section 11Reports only
SessionsIn-app video on the thread page with the 30-minute banner, entry at start, about 5 minutes graceNo
TransportRealtime required before launchNo
CalendarReal Google Calendar and Microsoft Graph sync in both directionsNo
ReviewsEmotion-led language, hidden numeric weightsRead only
RetentionTranscripts 4 weeks to 12 months; safety and vetting evidence 6 years; logs 12 monthsNo
RevenueNo advertising, data monetisation, crisis paywalls, priority booking or discount bundlesConstraint

Open questions for sign-off

#QuestionRecommendation
1Realtime: managed service or self-hosted WebSockets over Redis pub/sub?Managed, with EU data residency and a DPA. Fewer moving parts for a small team; revisit on cost at stage 3.
2Video providerShortlist providers with EU residency, embeddable SDKs, no recording by default and per-minute pricing. Decide before building session routes' media layer.
3When does the guide's money release: at completion, or after the 7-day dispute window in the policy documents?Reconcile the two before live mode. Holding until the dispute window closes simplifies refunds but strengthens the e-money question.
4How is a session marked complete: guide click, automatic after end plus grace, or seeker confirmation?Automatic after end plus grace using video join records, with a dispute route. Removes the manual step and the "complete before it happened" risk.
5Stripe Connect account type and dashboard access for guidesDecide with Stripe during compliance review.
6Staff role set and permissionsStart with support, moderator, senior moderator and administrator; document what each can see.
7Three parked category-risk calls: Recovery, Pregnancy Loss, and the structure of Domestic Abuse (the seed uses the defaults)Decide before guide onboarding enforces category risk.
8Object storage provider for documents and exportsAny S3-compatible store with an EU-only jurisdiction option and server-side encryption.
9Matching and safety triage from the prototype (AI matching, crisis and regulated-advice detection)Treat triage as a safety feature with its own review; defer AI matching until after launch.
10Suspended guides' future bookingsCancel and refund automatically on suspension or removal; today nothing happens.

27Glossary

TermMeaning
SeekerA person looking for support. Every account has a seeker profile.
GuideA vetted person offering support from their own lived experience.
IntroA free 15-minute first session, required before paid sessions with non-free guides.
PackageA bookable session of a set duration and price offered by a guide.
Pricing modelPAID (fixed price), PAY_WHAT_YOU_CAN (optional contribution after the session) or FREE.
ContributionThe optional post-session payment for pay-what-you-can guides; €0 is a valid, unshamed answer.
Hold then releaseThe platform takes the full payment and transfers the guide's share only after the session.
CreditsPrepaid units seekers spend on messages after their free allowance.
BookableA guide seekers can book: approved, trained and, for paid guides, able to take payments.
Legal holdA flag that exempts a thread or report from deletion because of a safety or legal need.
Confirm on renderThe current pattern of recording a Stripe payment when the return page loads, instead of by webhook.
OutboxA table of pending side effects written with a state change and executed by a worker.