KYC
Purpose
Section titled “Purpose”Document identity verification behavior for NSFW character creation.
18+ browsing before character creation
Section titled “18+ browsing before character creation”The Explore 18+ category is offered once an account has ever held an activated
subscription (hasSubscribed). A character is not required. Abandoned checkouts
without an activated subscription do not qualify; former subscribers do.
The category can be listed before age confirmation, but its media is served only
after the account either confirms it is 18 or older or has approved identity
verification. The same subscription and age rules control the 18+ content
toggle. Existing reviewer restrictions and payment-processor demo rules remain.
The profile 18+ of myself Verify entry also uses subscription history, so current
and former subscribers can enter the existing KYC flow before creating a
character. Never-subscribed accounts do not see that entry. Existing verification,
character creation, and generation requirements are unchanged.
Selecting an 18+ Explore preset with subscription history and age proof opens
the VI/RI chooser when the nsfw-onboarding flag is enabled, the character list
has loaded, and the account has no finished character. The RI option
requires approved account KYC; otherwise it offers Verify and opens the same
existing onboarding flow as the profile menu, including for awaiting or rejected
KYC. The VI option opens the shop.
Approved users resume their unfinished 18+ RI draft, including the one gifted by
KYC approval, rather than an unfinished SFW draft. SFW preset handling is unchanged.
See apps/brisket/src/features/explore-page/hooks/use-handle-select-example.tsx
and apps/brisket/src/features/explore-page/components/character-required-dialog/.
The character purchase dialog offers the same Verify path. With the
nsfw-onboarding flag enabled and 18+ content on, its character type picker
appears for approved account KYC and for accounts with subscription history and
age confirmation. Before KYC, 18+ of myself shows Verify and opens the same
existing onboarding flow instead of buying a character slot. 18+ AI influencer
still requires approved KYC, because the legacy KYC start always records a
real-person pick. See
apps/brisket/src/features/character/components/character-purchase-dialog/index.tsx
and apps/brisket/src/server/api/routers/kyc.ts.
See apps/sirloin/internal/app/services/media/taghelpers.go,
apps/sirloin/internal/app/services/billing/haseversubscribed.go, and
apps/brisket/src/features/character-page-ccv4/hooks/use-nsfw-onboarding.tsx.
Participants
Section titled “Participants”- Brisket initiates KYC-related user action and displays verification status.
- Sirloin owns account-level KYC state relevant to NSFW access.
- Ondato handles external identity verification.
- S3 stores KYC image artifacts used for NSFW character verification.
- Clerk supplies the user email that Sirloin passes to Ondato for the verification record.
- Strip displays account-level KYC status with links to the Ondato IDV page and Operator Station.
Sequence
Section titled “Sequence”sequenceDiagram participant Brisket participant Sirloin participant Clerk participant Ondato participant S3 Brisket->>Sirloin: Start KYC verification Sirloin->>Clerk: Look up user email Sirloin->>Ondato: Create verification (with email) Sirloin-->>Brisket: verification URL / ID Brisket->>Ondato: User completes verification Ondato-->>Sirloin: Verification callback/status Sirloin->>S3: Store KYC image artifact Sirloin->>Sirloin: Gift draft NSFW real character once
Onboarding Intro Status Check
Section titled “Onboarding Intro Status Check”The first onboarding screen (/characters?n=0) calls CheckKYCVerification
once when it mounts. The protected Brisket query supplies the signed-in user’s
ID; Sirloin reads only that account’s current session using the existing Ondato
CheckIdentification client. The check does not create sessions, synchronize
approval, or grant access.
An Ondato identification that is awaiting processing or approved but not yet
synced to the account displays Verification pending on the intro. Its
Continue button returns to Explore. No identification yet, or an awaiting
identification with reason Unfinished, retains the existing verification flow,
including after abandonment or expiry before submission. Rejected attempts keep
the existing retry flow. Accounts already approved in Sirloin keep their existing
onboarding behavior without a provider lookup.
Continue is disabled while the check loads; a failed or unknown provider response
offers Try again instead of advancing to verification. There is no polling or
second check inside StartKYCVerification; changes in another tab after this
check are not detected. Starting a session still happens only when the user
clicks Start verification on the next screen.
See apps/brisket/src/features/character-page-ccv4/views/nsfw-onboarding/steps/intro/index.tsx
and apps/sirloin/internal/app/services/kyc/checkkycverification.go.
Ondato Email Propagation
Section titled “Ondato Email Propagation”When Sirloin starts a KYC verification (startkycverification.go), it fetches
the user’s primary email from Clerk and sends it in the email field of
Ondato’s POST /v1/identity-verifications request. This makes the user email
visible in the Ondato dashboard for identity management. If the Clerk lookup
fails (e.g. missing API key in test environments), the email is omitted from
the request and verification continues normally.
Brisket Return Screen And Polling
Section titled “Brisket Return Screen And Polling”Ondato returns the user to Brisket’s /verification page. For a signed-in user
that page polls subscription.getCurrentUsage every 3 seconds for up to 60
seconds (MAX_RETRIES * RETRY_INTERVAL in apps/brisket/src/app/verification/page.tsx)
and reacts to kyc_identification_status:
APPROVED— redirect into NSFW onboarding.REJECTED— the “Verification Failed” screen, which invites another attempt.- neither yet — a spinner for the first 15 seconds (
SHOW_PENDING_AFTER), then the “Verification in progress” screen.
The wait is therefore two-stage. At 15 seconds the spinner gives way to the
pending screen, but polling does not stop there: it continues behind that screen
for the rest of the 60-second budget, so an APPROVED redirect or a REJECTED
failure screen can still arrive while the user is reading it. If the budget runs
out first, the pending screen simply stays as the terminal state.
The pending screen exists because Ondato often takes longer than the poll budget to produce a verdict. A timeout is not a rejection, so it must never be shown as one: users who are actually approved would otherwise reset and redo verification, and Ondato refuses the second attempt as a duplicate. The pending copy states that the account updates itself and deliberately does not offer a retry. The account menu shows a “Verified” label alongside the 18+ toggle once account KYC is approved, so “Reset verification” cannot be read as the retry affordance.
Strip Operator Visibility
Section titled “Strip Operator Visibility”Strip surfaces account-level KYC details on the user details modal
(userdetails.templ). The StripUser message includes four fields populated
from users.kyc_verifications (the current row, is_current=TRUE):
ondato_verification_id— links to the Ondato IDV verification pageondato_kyc_identification_id— links to the Ondato Operator Station KYC viewkyc_status—awaiting,approved,rejected, orresetkyc_status_reason— free-text reason (e.g.DuplicatedInfo)
These are populated via scalar subqueries in StripListUsers so they add no
additional round-trips. The operator UI shows a status badge and conditional
links to Ondato IDV and OS.
Strip KYC bypass is account-level. Eligible operators can approve the current
user KYC row from the user details modal; Sirloin records a synthetic
strip-... Ondato verification ID with status=approved. The operator controls
and badges carry distinct names, and they are not interchangeable:
- Bypass KYC — the button on the user details modal that creates the
bypassed row (
apps/strip/internal/app/templates/details/userdetails.templ:139). Eligibility is permissive outside production and restricted to listed domains and beta addresses in production (apps/sirloin/internal/app/services/strip/kycpolicy.go:8). - Strip bypass — the badge shown when the current verification id carries the
strip-prefix (apps/strip/internal/app/templates/details/userdetails.templ:152). - KYC bypass active — the status line beside the KYC badge.
- Likeness bypass / Bypass likeness for 24h — a character-level,
time-boxed control on the character details modal
(
apps/strip/internal/app/templates/details/characterdetails.templ:204-233). It affects legacy Kitsune onboarding scoring, not account KYC and not recipe-driven identity validation. Do not read it as the same switch.
A bypassed row is approved but carries no selfie, so kyc_image_path stays
empty. In the recipe-driven adult flow that does not skip the face comparison: it
substitutes the reference with the character’s own bound generation/face_frontal
asset and records the anchor as strip-bypass:<account id>, which cannot collide
with an Ondato identification id
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:120-156). See
Character Adult Identity Validation.
Character-local Ondato columns remain for compatibility with in-flight sessions and historical fallbacks only. Strip account-level sync lives on the user details modal; the old character-level Ondato sync route and control have been removed.
kyc_image_path on the current approved row is a mutable pointer, not a handle
on the Ondato artifact. Approving a contested likeness review copies the approved
face_frontal image to users/<user id>/kyc/likeness-reference.jpg and repoints
the row at it (apps/sirloin/internal/app/services/strip/striplikenessreviews.go:473,
apps/sirloin/internal/pkg/storage/userkycverifications.go:310), so the account
stops failing likeness checks against a stale selfie. The Ondato selfie is keyed
by verification id and is not deleted, so the original artifact survives the
promotion. Note that this account-level column now has a second consumer: the
recipe-driven adult flow reads it as its identity reference. Recipe-driven
characters cannot request a review, so they never cause a promotion, but an
account promoted while it held a legacy character keeps that pointer after the
legacy character is gone. See
Character Adult Identity Validation
and Contested Likeness Review.
Account KYC is authoritative for current KYC actions and NSFW gates. Approved account KYC does not cascade verification state to character records; it does idempotently create one draft NSFW real character for users without an existing NSFW character. The KYC handler can reuse a verified character’s Ondato verification ID only when the user lacks their own current account KYC record.
Duplicate KYC Outcomes
Section titled “Duplicate KYC Outcomes”Ondato can reject a KYC identification with status_reason=DuplicatedInfo.
That reason alone is not enough to approve local account KYC. Sirloin only
promotes Rejected + DuplicatedInfo to approved account KYC when the same
user_id already has an approved, real Ondato-backed KYC row in
users.kyc_verifications. Without that same-user prior approval, Sirloin stores
the result as rejected and keeps kyc_status_reason=DuplicatedInfo visible in
Strip.
CS checklist for duplicate reports:
- Check Strip user details first; account KYC is the source of truth.
- If Strip shows
approvedwithDuplicatedInfo, the duplicate matched a prior approved KYC for that user and the user should not be sent through KYC again. - If Strip shows
rejectedwithDuplicatedInfo, the duplicate was not auto-approved; escalate for manual review instead of treating the Ondato duplicate as a pass. - Use
Sync from Ondatowhen Strip looks stale and a real Ondato verification or KYC identification is available. - True duplicate current-account merge/linking is separate future work.
Third-Party Rules
Section titled “Third-Party Rules”Rules here come from the vendor, not from Beef code. Treat them as the vendor contract that our handling must follow.
Ondato: only the latest attempt is valid
Section titled “Ondato: only the latest attempt is valid”One Ondato identification can hold several attempts, and each attempt keeps its own photos. Only the latest attempt, the one with the highest number, is valid. An approved status applies only to that attempt’s photos. If an identification’s highest attempt is 3, the approval covers attempt 3 photos. Photos from earlier attempts are not approved and are not identity evidence.
Ondato reviews each attempt independently, and its reviewers cannot see earlier attempts. Different people can therefore appear across attempts of one identification. The extracted document data belongs to the approved attempt.
Sirloin follows this rule when it stores the KYC selfie:
- Every Ondato face and document file lists its attempt numbers in
usedForVerificationAttempts. The latest attempt is the highest number across all of them. - Sirloin keeps only the face files of that attempt
(
apps/sirloin/internal/pkg/ondato/client.go:511). - From those, it takes the
FaceAnimationfile. If the attempt has none, it takes a still JPEG or PNG face image (apps/sirloin/internal/pkg/ondato/client.go:531). - If the latest attempt has neither, Sirloin never falls back to a face from
an earlier attempt. It clears
kyc_image_pathon the current approved account KYC row and logs an error. Without a selfie the account has no usable character identity, so 18+ is blocked until the user verifies again (apps/sirloin/internal/pkg/models/userkycverification.go:67). - Strip
Sync from Ondatoreports that case to the operator as a failed precondition, after it records the sync and akyc_selfie_missingaudit field. A failure to reach Ondato, download, convert, or upload the selfie keeps the stored path.
Both the Ondato webhook and Strip Sync from Ondato use this selection.
Selfies stored before this rule was applied came from the earliest attempt and
are not re-synced automatically.
Source: Ondato support, confirmed on TRUCOM-62 and accepted as a third-party rule on 2026-10-15.
Source Links
Section titled “Source Links”- apps/brisket/src/server/api/routers/kyc.ts
- apps/brisket/src/app/verification/page.tsx
- apps/sirloin/internal/app/services/characters/createcharacter.go
- apps/sirloin/internal/app/services/kyc/startkycverification.go
- apps/sirloin/internal/pkg/storage/characters.go
- apps/sirloin/internal/pkg/ondato/client.go
State Transitions
Section titled “State Transitions”Users start verification from Brisket’s api.kyc router. Sirloin records the Ondato verification ID and KYC state in users.kyc_verifications, with one current row per user. Character-local Ondato columns remain for compatibility with in-flight sessions and legacy fallback reads, but account KYC is authoritative for new NSFW gates.
Users may reset approved account KYC only before any successful 18+ generation on an active 18+ character. Eligibility and the reset RPC both count non-deleted NSFW media with MEDIA_STATUS_AVAILABLE on non-deleted 18+ characters; deleted media and media on deleted 18+ characters do not block reset. There is no manual-photo marker requirement.
Invariants
Section titled “Invariants”- NSFW character creation requires approved account KYC.
- Sirloin owns the account KYC state that gates NSFW character creation and generation.
- Strip test bypass approves account KYC, not a single character.
- A bypassed account KYC row has no selfie; downstream identity checks substitute a reference rather than skipping the check, and the substitution is legible in the recorded anchor.
- Approved account KYC creates at most one draft NSFW real character;
users.credits.kyc_fac_bonus_grantedmakes this idempotent across webhook and poller retries. - Ondato is the external verifier.
- User email is propagated to Ondato on verification creation for dashboard visibility.
Error Paths
Section titled “Error Paths”Missing approved account KYC blocks NSFW character creation and generation with FailedPrecondition. Reset is denied with FailedPrecondition after any successful 18+ generation.
Tests And Verification
Section titled “Tests And Verification”- cd apps/sirloin && make run-tests
- cd apps/brisket && pnpm test