Character identity validation is workflow-authored, and Sirloin trusts the output contract
Context
Section titled “Context”The adult capability lets a customer turn a character into a depiction of themselves. The product promise — and the legal position — is that the person in the uploaded photographs is the person who passed identity verification. Somebody has to check that, and something durable has to record that the check passed.
Three forces shape where that check can live.
Sirloin cannot see the graph. A recipe pins Brain workflows by UUID, and
Sirloin only ever reads the workflow contract: purpose, inputBindings,
outputSchema, assetOutputs. That is deliberate — it is what
character compute graphs pin by Brain workflow UUID
and character creation is composed from versioned recipe capabilities
bought us. It also means Sirloin cannot verify that a workflow does the thing its
purpose claims.
A failed match is an ordinary answer, not an outage.
Character workflow verdicts are outputs, not execution failures
already settled that a decline rides the failure field of a succeeded
execution. An identity mismatch is exactly that shape: the customer uploaded a
photo of somebody else, and we must say so in authored copy and let them retry.
The identity itself is Sirloin’s, not the recipe’s. The KYC selfie is an account artifact written by the Ondato integration (see KYC). A recipe must be able to use it without being able to choose it, and without the browser ever seeing its object key.
There is also a fourth force that is easy to underestimate: Strip operators need a working adult flow on accounts that have no real Ondato verification — QA accounts and a small beta cohort. Whatever we build has to have a bypass, and the bypass has to be honest about itself in the database.
The retired legacy Kitsune path is the counter-example this decision reacts to. There, the reference image was an argument to a scoring endpoint, and several call sites quietly substituted it — with the uploaded image itself, or with a selfie seeded from the customer’s own first upload. A comparison that ran against a substituted reference produced a record indistinguishable from a genuine match. The reference was implicit, so nothing recorded which identity had been proven.
Decision
Section titled “Decision”Identity matching is performed by an authored Brain workflow that declares
purpose = character.identity-validation; Sirloin’s role is to resolve and pin
the identity at dispatch, refuse to dispatch without one, validate the returned
output contract, and record an attestation naming the identity that was proven.
Sirloin resolves the identity; the recipe only names it
Section titled “Sirloin resolves the identity; the recipe only names it”$.identity.kyc_selfie is the sole binding a recipe may read that is not a
$.session.* path
(apps/sirloin/internal/app/services/characterrecipevalidation.go:1623,
apps/sirloin/internal/pkg/storage/characterexecutionbindings.go:20). The
recipe declares that it wants the selfie; it cannot say which one.
At dispatch, Sirloin detects that binding
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:74), resolves
the account’s current KYC row
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:102), pins the
resulting anchor onto the session
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:160), and
writes the selfie object key into the execution’s audited input snapshot
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:188). The
anchor is pinned once per session with COALESCE and re-asserted on every
subsequent dispatch
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:171), so a
session cannot change the person it is proving halfway through.
No identity means no execution
Section titled “No identity means no execution”An account with no current KYC row, an unapproved one, or an approved one missing
its identification id or selfie path is refused with
ErrCharacterAdultIdentityUnverified
(apps/sirloin/internal/pkg/storage/characterfoundationerrors.go:101) before the
workflow is dispatched. Independently, an $.identity.kyc_selfie binding that
resolves to anything other than a non-empty string fails input resolution
(apps/sirloin/internal/pkg/storage/characterexecutionbindings.go:90-94). A
workflow that expects a reference never runs without one.
The Strip bypass substitutes the reference; it never skips the comparison
Section titled “The Strip bypass substitutes the reference; it never skips the comparison”A Strip KYC bypass is an approved account KYC row carrying a synthetic
strip- verification id and no selfie
(apps/sirloin/internal/app/services/strip/stripbypassuserkyc.go:60,
apps/sirloin/internal/pkg/models/userkycverification.go:42), created only for
eligible operators and beta accounts
(apps/sirloin/internal/app/services/strip/kycpolicy.go:8) via the Bypass KYC
control in Strip (apps/strip/internal/app/templates/details/userdetails.templ:139).
For such an account, dispatch takes a second branch
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:120): the
reference becomes the session’s bound generation/face_frontal asset — the
character’s own validated face crop — and the anchor becomes
strip-bypass:<account id>
(apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:156,
apps/sirloin/internal/pkg/storage/characteradult.go:85). If no such asset is
bound, the bypass is refused like any other missing identity.
Two properties are load-bearing and are the whole point of writing this down.
The comparison still runs. The image:face_compare node executes with a
different reference_image_path, the condition still branches on
{{nodes.compare.match}}, and the decline route is still reachable. A bypass
weakens what is proven; it never removes the proof step. This is the opposite
of the legacy arrangement, where the equivalent bypasses collapsed into a
comparison of an image against itself with nothing to show for it.
The bypass is legible in the data. strip-bypass: can never collide with an
Ondato identification id, so an attestation granted under a bypass is
distinguishable from a verified one by inspection, and
resolveCharacterAdultIdentity reports the state explicitly
(apps/sirloin/internal/pkg/storage/characteradult.go:123). The adult-slot
uniqueness index is keyed on the anchor, so a bypassed account cannot silently
occupy a verified person’s slot.
That legibility covers the bypass only. It does not yet extend to a verified account whose reference image was replaced through another path — see the mutable-reference risk below, which this decision does not solve.
Acceptance is the output contract plus exactly one asset
Section titled “Acceptance is the output contract plus exactly one asset”On a succeeded execution Sirloin reads failure from the projected output
(apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:512), validates
the payload against the pinned outputSchema
(apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:516), requires
the output to echo the workflow’s own source_role_key
(apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1060), and
requires exactly one asset in the declared slot
(apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1069). Recipe
validation enforces the schema shape at publish time — one optional single-asset
generation output, and if failure is null then assets is required
(apps/sirloin/internal/app/services/characterrecipevalidation.go:1484) — and a
literal, non-binding source_role_key
(apps/sirloin/internal/app/services/characterrecipevalidation.go:1497).
Together these mean an accepting run must have produced a materialized asset from the slot it claims to have checked. An empty or partial output is refused rather than read as consent.
The attestation is the durable fact
Section titled “The attestation is the durable fact”A passed match grants an attestation on the upload that was matched, keyed on
(asset_id, identity_anchor) and stamped with the execution that granted it
(apps/sirloin/internal/pkg/storage/characterassetattestation.go:62), inside the
same transaction that records the result
(apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1146). The anchor
comes from the session, not from live KYC, so the record names the identity the
check actually ran against. A session that pinned nothing refuses to attest.
Attestations are then republished as attested_<role> session values
(apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1191,
apps/sirloin/internal/pkg/storage/characterassetrole.go:157) so recipe
conditions can ask which slots still owe a check — the only channel through which
a recipe can observe the outcome.
This applies to workflows declaring purpose = character.identity-validation in
the POC fixtures and the recipes that pin them
(apps/flank/app/fixtures/character-recipes/initial-ri-sfw.json). It covers face
matching only. Protected-set moderation — CSAM, celebrity, illegal content —
stays in its own nodes and is not part of this contract.
Consequences
Section titled “Consequences”Benefits
Section titled “Benefits”- The identity a run proved is a row, not an inference. An auditor can answer
“which identity anchor was this photo matched against, and by which execution”
from
characters.asset_identity_attestationsalone. Which image stood for that anchor is a separate question the anchor does not yet answer — see the mutable-reference risk below. - A bypassed proof and a verified proof are different values in the same column, so no report has to guess between those two.
- Recipe authors can change matching policy — thresholds, which slots need a match, decline copy — without a Sirloin release, because the graph owns the logic and the contract owns the boundary.
- The attestation rides asset lineage (character assets use scope plus role), so slots derived from a matched upload inherit the answer instead of each needing its own check.
- Refusing at dispatch keeps an unverifiable account off the workflow entirely, rather than relying on a graph to notice a missing reference.
Costs and risks
Section titled “Costs and risks”-
The verified reference is a mutable pointer, and an existing substitution is invisible. Dispatch reads
kyc_image_pathoff the current KYC row (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:120), and that column is not immutable: approving a contested likeness review repoints it at an operator-approved, customer-suppliedface_frontalimage (apps/sirloin/internal/app/services/strip/striplikenessreviews.go:473,apps/sirloin/internal/pkg/storage/userkycverifications.go:310). The anchor stays the genuine Ondato identification id, so — unlike the bypass — an attestation proved against a promoted reference carries no sign of it.This is a residue problem, not an ongoing one. A review must be requested per character, recipe-driven characters have no such affordance, and the legacy path that writes the column retires with the legacy characters, so the adult flow cannot originate a new promotion. What does not retire is the column: it is account-scoped, deleting legacy characters does not restore it, and as of 2026-09-08 roughly 10% of current approved KYC rows in production are already repointed, still accruing while the legacy path runs. Every affected row keeps a real verification id and the Ondato artifact survives under its own key, so the pre-launch task is a one-time reset of those rows rather than a data recovery exercise. Reading a typed, unrepointable Ondato-artifact column is the durable version of the same fix, and is worth it only if the guarantee must survive some future writer.
-
purposeis a declaration, not an enforced graph shape. Nothing checks that a workflow claimingcharacter.identity-validationcontains animage:face_comparenode. Sirloin validates only the contract, and Brain’s recipe validator reads onlyassetOutputs(apps/brain/src/modules/application/character-runtime/character-recipe.validator.ts:743-757). A graph that returnsfailure: nulland one asset earns a full attestation without comparing anything. This is the stronger form of a risk already named in verdicts are outputs: there, a workflow could forget to report a decline; here it can forget to perform the check. Publish-time enforcement in Brain is the follow-up. -
Under bypass, the first slot passes tautologically. In the shipped adult flow the
face_chestmatch runs againstgeneration/face_frontal, which the SFW creation flow derived from that sameface_chestupload. The comparison is therefore an upload against a crop of itself and always matches. Later slots are genuinely anchored to the first photo, but the first one proves nothing.Current behaviour attests it like any other passed match: nothing on the accept path branches on the anchor type, so a bypassed run grants an attestation and publishes
attested_<role>normally, with only the anchor value differing. Recording that first slot as bypassed instead — and deciding whether it should publish its stage marker at all — is the recommended change, not what the code does today. Note this is a different layer fromcapability_layers.adult.verification_state(apps/sirloin/internal/pkg/storage/charactercapability.go:54-59), which already recordsapprovedorbypassedfor how the adult capability grant was authorized; that field is not a per-slot match result and must not be conflated with one. -
The engine’s fail-closed behaviour is emergent, not stated. A skipped comparison prunes the accept branch through three independent gates — liveness, branch, and data (
apps/brain/src/modules/domain/workflow/engine/executor.service.ts:344,:364,:374) — and an uncaptured node failure fails the execution (:642). None of that is part of the workflow contract, so an engine change could weaken it without any contract test noticing. The fixture-level regression test is the guard, and it must exist. -
Bypass eligibility is a hardcoded allowlist of domains and addresses in Go (
apps/sirloin/internal/app/services/strip/kycpolicy.go:8), so changing the beta cohort is a deploy. -
Anchor pinning is one-way per session. An account whose KYC is legitimately replaced mid-session cannot finish that session under the new identity; it has to start a new one. That is the safe direction, but it is a support path somebody will hit.
Rollback
Section titled “Rollback”Repin each recipe to a version whose adult flow omits the identity-validation stages. The attestation table and the anchor column are additive and can stay; they are read only by adult flows. Do not roll back Sirloin’s dispatch-time resolution while a recipe still pins an identity-validation workflow — the workflow would then run without a reference and the binding check would fail every execution.
Alternatives Considered
Section titled “Alternatives Considered”Require the face_compare node in Sirloin’s recipe validator. Rejected for
now: Sirloin does not receive graphs, only contracts, and exporting graph shape
across the boundary would undo the separation that
the workflow pin ADR
established. Enforcing it in Brain’s publish-time validator, which does hold the
graph, is the better place and is the recommended follow-up rather than a
rejected option.
Have Brain return proof of which nodes ran. The execution log already records
per-node status, and Sirloin could require an image:face_compare entry before
attesting. Rejected as the first move because it makes the node-level execution
log part of a cross-service contract — a much heavier commitment than a
publish-time shape check, for the same guarantee.
Keep identity matching inside Sirloin, as the legacy path did. Rejected: it re-centralizes product logic that recipes exist to own, and it was the arrangement that allowed reference substitution to spread across call sites unnoticed.
Skip the comparison entirely for bypassed accounts. Rejected because it produces exactly the record we are trying to leave behind: an accepted photo with nothing indicating whether anything was checked. Substituting the reference keeps the run, the decline route, and the audit shape identical to a verified run, with only the anchor distinguishing them.