Skip to content

Character Adult Identity Validation

Document how the recipe-driven adult (18+) flow proves that an uploaded photo depicts the person who passed KYC, how the Strip bypass changes what is proven, and what a passed match leaves behind.

This is the T-Bone path, reached through a character recipe. It is not the retired Kitsune onboarding path, and it is not the edit-input face gate used during generation.

Availability. The frontmatter status is a documentation field and says nothing about rollout. This flow is wired end to end — the recipe, the workflows, and the commit path are all in the repository — but customers are not running it: as of 2026-09-08 characters.character_sessions has zero rows in production, so no identity attestation has ever been granted there. Read this page as the contract the code implements, not as a description of live customer traffic, and check the current adult-capability rollout items in Linear before assuming otherwise.

  • Brisket runs the recipe and submits stage actions; it never sees the selfie object key.
  • Sirloin resolves the account identity, pins it to the session, dispatches the execution, validates the result, and records the attestation.
  • Brain executes the pinned workflow, whose image:face_compare node calls Round.
  • Round computes LVFace embeddings for both images.
  • S3/R2 holds the KYC selfie and the character’s uploads.
sequenceDiagram
  participant Brisket
  participant Sirloin
  participant Brain
  participant Round
  Brisket->>Sirloin: stage action (EXECUTE identity validation)
  Sirloin->>Sirloin: resolve current account KYC
  alt no approved identity
    Sirloin-->>Brisket: ErrCharacterAdultIdentityUnverified
  else identity resolved
    Sirloin->>Sirloin: pin adult_identity_anchor on the session
    Sirloin->>Sirloin: hydrate $.identity.kyc_selfie into the input snapshot
    Sirloin->>Brain: dispatch execution with resolved inputs
    Brain->>Round: face-embedding (reference, comparison)
    Round-->>Brain: embeddings
    Brain->>Brain: face_compare -> condition -> accept or decline output
    Brain-->>Sirloin: completion webhook (outputs, assets)
    alt failure is null
      Sirloin->>Sirloin: validate schema, role echo, single asset
      Sirloin->>Sirloin: bind asset, grant attestation, refresh attested_<role>
    else failure present
      Sirloin->>Sirloin: drop the slot, expose the repair stage
    end
    Sirloin-->>Brisket: operation result
  end

A recipe asks for the selfie by binding kyc_selfie_path to $.identity.kyc_selfie. Apart from $.session.*, that is the only permitted binding source, with one companion: $.identity.kyc_selfie_alternative is allowed only in a binding set that also binds $.identity.kyc_selfie (apps/sirloin/internal/app/services/characterrecipevalidation.go:103, :1897-1898; apps/sirloin/internal/pkg/storage/characterexecutionbindings.go:21-22).

When an execution’s pinned workflow carries the selfie binding (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:80-84), Sirloin resolves the account’s current KYC row inside the dispatch transaction (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:145):

  • Verified account — anchor is the Ondato identification id, reference is whatever kyc_image_path currently points at. A missing identification id or reference path is treated as unverified. If an operator promoted an alternative reference, its path is resolved too.
  • Strip-bypassed account — see Strip KYC Bypass On T-Bone.

The resolved anchor is pinned to the session with COALESCE, so the first identity-validation execution fixes it and every later one must agree (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:211-216). The selfie path is written into the execution’s audited input snapshot, which bumps the input digest and revision (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:249-256); an execution that already recorded a different selfie is rejected as corrupted state.

kyc_selfie_alternative is always hydrated next to the selfie, as "" when no alternative exists, so a binding to it never fails on absence. It is read only from a row that passes HasUsableCharacterIdentity (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:160-170, apps/sirloin/internal/pkg/models/userkycverification.go:67), and the bypass branch never sets it, so a Strip-bypassed account yields "". A retry keeps the alternative the execution first recorded, so an operator promoting a photo mid-flight cannot fail a running execution (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:259-266).

The reference is a pointer, not an Ondato artifact

Section titled “The reference is a pointer, not an Ondato artifact”

kyc_image_path normally holds the Ondato selfie at users/<user id>/kyc/<verification id>.jpg, written by the KYC webhook. It is not immutable. Approving a contested likeness review copies the approved face_frontal image to users/<user id>/kyc/likeness-reference.jpg and repoints the current approved KYC row at it (apps/sirloin/internal/app/services/strip/striplikenessreviews.go:473, apps/sirloin/internal/pkg/storage/userkycverifications.go:310). That is a legacy Kitsune review path, but the column it writes is account-level, and this flow reads the same column.

The consequence is narrow but sharp: for such an account the comparison runs against an operator-approved, customer-supplied photograph rather than the identity-verification artifact, while the anchor stays the genuine Ondato identification id. Unlike the Strip bypass, this substitution leaves no marker — the resulting attestation is indistinguishable from one proved against the Ondato selfie.

This flow cannot cause it. A likeness review is requested per character (apps/sirloin/internal/app/services/characters/requestlikenessmanualreview.go:13), recipe-driven characters have no such affordance, and the path retires with the legacy characters. What it inherits is the residue: the column is account-scoped, so an account that was promoted while it held a legacy character carries that pointer into its adult flow, and deleting the legacy character does not restore it.

Until those rows are reset, or dispatch reads a source that cannot be repointed, read an attestation on a verified account as “matched against the account’s current face reference”, not as “matched against the KYC selfie”. The Ondato artifact survives the promotion — it lives under a different key and is not deleted — so the distinction is recoverable, not lost. See the risk section of the identity validation ADR.

Some accounts pass Ondato and still cannot match their own photos against the Ondato selfie (lighting, age, a poor capture). An operator can promote a customer photo to a second reference for the account. It supplements the Ondato selfie; it never replaces it, and it never touches kyc_image_path.

  • Who. Strip operators with the sync:ondato permission (root and admin), from the character dataset page (apps/strip/cmd/app/main.go:515-516, apps/strip/internal/app/templates/pages/character_dataset.templ:254-315). The page offers promotion only on photos the dataset read flags with the RPC’s own predicate, and only when the account is promotable; it shows the Ondato selfie beside the current alternative and in the confirm dialog (apps/sirloin/internal/pkg/storage/userkycalternative.go:57, apps/sirloin/internal/pkg/storage/stripcharacterdataset.go:282).
  • What can be promoted. A photo the customer supplied, owned by the account: an upload the character’s validation rejected as an identity mismatch (a rejection code starting with IDENTITY_MISMATCH, such as IDENTITY_MISMATCH_FULL_BODY; nudity, age, or face-visibility rejections are refused), or an available upload or migrated asset bound in the character’s live version or in its open session’s working set. Generated images and photos bound only in older versions are refused (apps/sirloin/internal/pkg/storage/userkycalternative.go:95-104, apps/sirloin/internal/pkg/storage/characterbindingversion.go:24, apps/sirloin/internal/pkg/storage/characterbindingversion.go:76). The account must have a current approved Ondato verification that is not a Strip bypass and carries both the Ondato identification id and the KYC selfie. One rule decides this for the dataset page, the RPC’s early check, and the locked write (apps/sirloin/internal/pkg/models/userkycverification.go:60). The RPC checks eligibility before it copies anything, then re-checks the KYC row under the lock (apps/sirloin/internal/app/services/strip/strippromotealternativekycimage.go:29).
  • Where it lives. The object is copied to users/<user id>/kyc/alternative-<asset id>-<attempt id><source ext>; each promotion writes a new object, and a failed write deletes only its own (apps/sirloin/internal/app/services/strip/strippromotealternativekycimage.go:86). The path, source asset, promoting admin, and time are columns on the current KYC row, all set or all null (apps/sirloin/internal/app/migrate/schema/175_alternative_kyc_reference.sql:4-29). A later promotion overwrites the earlier one.
  • Write and audit. Promotion takes the KYC lock, requires the current row to be the verification the RPC checked and to pass that same rule, and writes a PROMOTE_ALTERNATIVE_KYC_IMAGE Strip audit entry in the same transaction (apps/sirloin/internal/pkg/storage/userkycalternative.go:153-175, apps/sirloin/internal/pkg/models/stripauditlog.go:30).
  • Lifetime. It belongs to the current KYC row. Re-syncing the same Ondato verification carries it forward; a new verification is a new identity proof and starts without one (apps/sirloin/internal/app/services/strip/stripsyncuserondatoverification.go:262-269).
  • Anchor unchanged. The session anchor is still the Ondato identification id (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:165-169), so promotion does not fork attestations by reference.
  • Marker. Unlike the legacy repoint above, this path is recorded. An attestation stores alternative_reference_present: a promoted alternative was present in the execution’s input snapshot, so the match could have used it (apps/sirloin/internal/pkg/storage/characterassetattestation.go:22-25, :98-110; apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1406-1415). It says the alternative was available, not that it decided the verdict.

The legacy likeness-review repoint, by contrast, overwrites kyc_image_path and leaves no marker.

Operators need the adult flow to work on accounts with no Ondato verification. Strip’s Bypass KYC control (apps/strip/internal/app/templates/details/userdetails.templ:139) writes an approved account KYC row with a synthetic strip- verification id and no selfie (apps/sirloin/internal/app/services/strip/stripbypassuserkyc.go:60), gated on an eligibility policy that is permissive outside production and restricted to listed domains and beta addresses in production (apps/sirloin/internal/app/services/strip/kycpolicy.go:8). Such a row reports IsStripBypass() (apps/sirloin/internal/pkg/models/userkycverification.go:42) and is surfaced in Strip as a Strip bypass badge (apps/strip/internal/app/templates/details/userdetails.templ:152).

On dispatch, a bypassed account takes the second branch (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:120):

Verified account Strip-bypassed account
Anchor Ondato identification id strip-bypass:<account id>
Reference image account KYC selfie in S3 session’s bound generation/face_frontal asset
Comparison runs yes yes
Decline route reachable yes yes
Refused when unavailable no identification id or selfie path no bound face_frontal asset

What the bypass changes is what is proven, not whether something is proven. The image:face_compare node executes either way; only its reference_image_path differs. The proof becomes “the same person as this character’s validated face reference” instead of “the person who passed KYC”, and the anchor says so: strip-bypass: cannot collide with an Ondato identification id (apps/sirloin/internal/pkg/storage/characteradult.go:85), and resolveCharacterAdultIdentity reports the bypassed state explicitly (apps/sirloin/internal/pkg/storage/characteradult.go:123).

Two consequences are worth knowing before reading an attestation row.

The first slot is tautological under bypass. In the shipped adult flow the face_chest match runs against generation/face_frontal, which the SFW creation flow derived from that same face_chest upload. The comparison therefore runs an image against a crop of itself, and is expected to clear the threshold on similarity alone — it proves nothing about identity. It is not guaranteed to return match: true: the service sets match only when cosine similarity meets the threshold, and a reference with no detectable face returns false regardless of provenance (apps/brain/src/modules/domain/character/services/reference-face-compare.service.ts:133). Treat the slot as unproven rather than as passed. The full_body and full_body_any matches that follow are genuinely anchored to that first photo.

A bypassed run is attested like any other. Nothing on the accept path branches on the anchor type: attestValidatedCharacterIdentitySource and refreshCharacterIdentityAttestedValues both run, so a bypassed match grants an attestation and publishes attested_<role> exactly as a verified one does (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1146, :1191). The only recorded difference is the anchor value. Separately, and at a different layer, capability_layers.adult.verification_state on the character records approved or bypassed for how the adult capability grant was authorized (apps/sirloin/internal/pkg/storage/characteradult.go:267, apps/sirloin/internal/pkg/storage/charactercapability.go:54-59) — that is a capability-grant fact, not a per-slot match result, and the two should not be read as one field. Recording the tautological first slot as bypassed rather than attested is a recommendation in the ADR, not current behaviour.

A separate, character-scoped lever exists and is unrelated to this flow. Strip’s Likeness bypass / Bypass likeness for 24h (apps/strip/internal/app/templates/details/characterdetails.templ:204-233) affects the legacy Kitsune scoring path, not recipe-driven identity validation. Do not read the two as the same switch.

The pinned graph is ri-adult-match-identity-dataset (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:3337, built at :2197):

  1. trigger receives character_id, source_assets, source_role_key and kyc_selfie_path, all required by the input schema (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:970-979).
  2. image:face_compare compares {{trigger.kyc_selfie_path}} against the selected upload (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:2285-2292, node at apps/brain/src/modules/domain/workflow/nodes/image/face-compare.node.ts:25). It calls ReferenceFaceCompareService (apps/brain/src/modules/domain/character/services/reference-face-compare.service.ts:54), which embeds both images through Round’s face-embedding model (apps/brain/src/modules/application/round/services/round-face-embedding.service.ts:39) and compares cosine similarity against the threshold, defaulting to 0.3 (apps/brain/src/modules/domain/character/services/reference-face-compare.service.ts:42).
  3. A condition node branches on {{nodes.compare.match}} (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:2212). The true route materializes the upload into its generation slot and emits an accept output; the false route emits a decline carrying an IDENTITY_MISMATCH_* code and authored copy.
  4. Brain projects the first executed output node into the completion payload (apps/brain/src/modules/domain/workflow/utils/character-completion-outputs.ts:13, apps/brain/src/modules/domain/workflow/processors/execution-runner.processor.ts:254). If no output node ran, the projection is empty (apps/brain/src/modules/domain/workflow/utils/character-completion-outputs.ts:30) and Sirloin rejects the commit.

Each pinned workflow has a -kyc-alternative twin (ids …0083 to …0092, for example ri-adult-match-identity-dataset-kyc-alternative at apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:3343-3347). The twin takes an extra kyc_selfie_alternative_path input and adds a guarded second comparison through kycAlternativeFallback (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:468-543):

  1. <compare>_alt_needed is true only when the primary match is not true and the alternative path is non-empty.
  2. <compare>_alt is a second image:face_compare against the alternative.
  3. <compare>_alt_settled joins both branches so a skipped alternative never skips the verdict.

The verdict becomes primary.match == true || alt.match == true. The original pins are unchanged and keep comparing against the Ondato selfie alone. The Flank recipes under apps/flank/app/fixtures/character-recipes/ repoint the likeness-check, repair and upload pins to their twins (…0084, …0086 to …0092) and bind kyc_selfie_alternative_path to $.identity.kyc_selfie_alternative. The identity-match twin (…0083) and the optional-slots check twin (…0085) currently have no recipe consumer, just as their originals (…0060, …0079) had none.

On a succeeded execution (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:445) Sirloin reads the verdict (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:512) and, on the accept path, requires all of: the payload validates against the pinned outputSchema (:516), the output echoes the workflow’s literal source_role_key (:1060), and exactly one asset arrives in the declared slot (:1069).

Only then does it bind the asset and grant an attestation on the upload that was matched — not on the generated output — keyed on (asset_id, identity_anchor) and stamped with the granting execution (apps/sirloin/internal/pkg/storage/characterassetattestation.go:62, apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1146). The anchor comes from the session, so the record names the identity the check actually ran against; a session that pinned nothing refuses to attest.

Attested slots are republished as attested_<role> session values on every passed match and every derive run (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1191, apps/sirloin/internal/pkg/storage/characterassetrole.go:157). Recipe conditions read those values to decide which stages still owe a check, and a slot that loses its attestation loses its marker rather than keeping a stale claim.

A declined run binds nothing, drops the staged slot, and exposes the conditional repair upload stage that follows the validation stage (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1264).

  • apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go
  • apps/sirloin/internal/pkg/storage/characterexecutionbindings.go
  • apps/sirloin/internal/pkg/storage/characterexecutioncommit.go
  • apps/sirloin/internal/pkg/storage/characterassetattestation.go
  • apps/sirloin/internal/pkg/storage/characteradult.go
  • apps/sirloin/internal/pkg/storage/userkycalternative.go
  • apps/sirloin/internal/app/services/strip/strippromotealternativekycimage.go
  • apps/brain/src/modules/domain/workflow/nodes/image/face-compare.node.ts
  • apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts
  • apps/flank/app/fixtures/character-recipes/initial-ri-sfw.json

The adult upgrade flow in initial-ri-sfw.json runs three identity validations in sequence, each on a status stage followed by a conditional repair upload stage: face_chest, then — after body references are derived — full_body and full_body_any. Each validation stage’s successor is guarded on the corresponding attested_<role> session value, so a slot already attested is skipped and a declined slot returns to its repair stage. Recipe validation enforces this pairing: an initial validation is an EXECUTE action on a status stage reading an existing unbound upload slot (apps/sirloin/internal/app/services/characterrecipecapabilityvalidation.go:626), and its repair is an UPLOAD action on the immediately following conditional stage (:656).

  • A recipe may name the selfie but never choose it; $.identity.kyc_selfie and $.identity.kyc_selfie_alternative are resolved server-side and never reach the browser.
  • An execution binding the selfie does not start without a resolved identity.
  • The session’s adult_identity_anchor is written once and re-asserted, never replaced.
  • Every currently pinned identity-validation workflow runs a comparison, and a bypass changes the reference rather than removing the check. This is a property of the pinned graphs, not of the character.identity-validation purpose: nothing enforces that an authored workflow contains an image:face_compare node. Verify the graph before relying on this for a newly pinned workflow, and see the risk section of the identity validation ADR.
  • An attestation names the anchor that was pinned at dispatch. A bypass-derived attestation is distinguishable by its strip-bypass: prefix; an attestation proved against a promoted likeness reference is not currently distinguishable from one proved against the Ondato selfie.
  • An accepting run produces exactly one asset in the slot it claims to have checked; an empty or partial output is refused.
  • Attestations attach to uploads; generated outputs inherit through lineage, not by their own check.
Condition Result
No current, approved account KYC ErrCharacterAdultIdentityUnverified at dispatch (apps/sirloin/internal/pkg/storage/characterfoundationerrors.go:101)
Approved KYC without identification id or selfie path same refusal
Bypassed account with no bound generation/face_frontal same refusal
Selfie binding resolves to empty or non-string ErrCharacterActionInputInvalid (apps/sirloin/internal/pkg/storage/characterexecutionbindings.go:90-94)
Session already pinned a different anchor dispatch refused
face_compare node throws, or Round is unavailable for the reference image uncaptured node failure fails the execution (apps/brain/src/modules/domain/workflow/engine/executor.service.ts:642)
match is false decline output with an IDENTITY_MISMATCH_* code; slot dropped, repair stage shown
Comparison never produced output accept branch pruned by the liveness and branch gates (apps/brain/src/modules/domain/workflow/engine/executor.service.ts:344-372); empty projection refused at commit
Accept output missing its asset or role echo ErrCharacterActionInputInvalid at commit
  • cd apps/sirloin && make run-tests
  • cd apps/brain && pnpm test
  • Integration coverage for the commit path lives in apps/sirloin/internal/pkg/storage/characteridentityvalidation_integration_test.go and apps/sirloin/internal/app/services/characteridentityvalidation_test.go.