Skip to content

Character identity validation is workflow-authored, and Sirloin trusts the output contract

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.

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.

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.

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.

  • 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_attestations alone. 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.
  • The verified reference is a mutable pointer, and an existing substitution is invisible. Dispatch reads kyc_image_path off 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-supplied face_frontal image (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.

  • purpose is a declaration, not an enforced graph shape. Nothing checks that a workflow claiming character.identity-validation contains an image:face_compare node. Sirloin validates only the contract, and Brain’s recipe validator reads only assetOutputs (apps/brain/src/modules/application/character-runtime/character-recipe.validator.ts:743-757). A graph that returns failure: null and 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_chest match runs against generation/face_frontal, which the SFW creation flow derived from that same face_chest upload. 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 from capability_layers.adult.verification_state (apps/sirloin/internal/pkg/storage/charactercapability.go:54-59), which already records approved or bypassed for 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.

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.

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.