Character Adult Identity Validation
Character Adult Identity Validation
Section titled “Character Adult Identity Validation”Purpose
Section titled “Purpose”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.
Participants
Section titled “Participants”- 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_comparenode calls Round. - Round computes LVFace embeddings for both images.
- S3/R2 holds the KYC selfie and the character’s uploads.
Sequence
Section titled “Sequence”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
Identity Resolution At Dispatch
Section titled “Identity Resolution At Dispatch”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_pathcurrently 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.
Alternative KYC reference
Section titled “Alternative KYC reference”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:ondatopermission (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 asIDENTITY_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_IMAGEStrip 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.
Strip KYC Bypass On T-Bone
Section titled “Strip KYC Bypass On T-Bone”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 Matching Workflow
Section titled “The Matching Workflow”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):
triggerreceivescharacter_id,source_assets,source_role_keyandkyc_selfie_path, all required by the input schema (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:970-979).image:face_comparecompares{{trigger.kyc_selfie_path}}against the selected upload (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts:2285-2292, node atapps/brain/src/modules/domain/workflow/nodes/image/face-compare.node.ts:25). It callsReferenceFaceCompareService(apps/brain/src/modules/domain/character/services/reference-face-compare.service.ts:54), which embeds both images through Round’sface-embeddingmodel (apps/brain/src/modules/application/round/services/round-face-embedding.service.ts:39) and compares cosine similarity against the threshold, defaulting to0.3(apps/brain/src/modules/domain/character/services/reference-face-compare.service.ts:42).- A
conditionnode 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 anIDENTITY_MISMATCH_*code and authored copy. - 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.
Alternative-reference pins
Section titled “Alternative-reference pins”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):
<compare>_alt_neededis true only when the primarymatchis not true and the alternative path is non-empty.<compare>_altis a secondimage:face_compareagainst the alternative.<compare>_alt_settledjoins 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.
Commit And Attestation
Section titled “Commit And Attestation”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).
Source Links
Section titled “Source Links”- 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
State Transitions
Section titled “State Transitions”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).
Invariants
Section titled “Invariants”- A recipe may name the selfie but never choose it;
$.identity.kyc_selfieand$.identity.kyc_selfie_alternativeare 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_anchoris 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-validationpurpose: nothing enforces that an authored workflow contains animage:face_comparenode. 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.
Error Paths
Section titled “Error Paths”| 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 |
Tests And Verification
Section titled “Tests And Verification”cd apps/sirloin && make run-testscd apps/brain && pnpm test- Integration coverage for the commit path lives in
apps/sirloin/internal/pkg/storage/characteridentityvalidation_integration_test.goandapps/sirloin/internal/app/services/characteridentityvalidation_test.go.