Skip to content

Character Recipe Engine

Character creation is family-discovered and recipe-flow-driven. A recipe family is the customer-visible catalog choice and eligibility-routing layer. The resolved recipe remains the executable definition. Post-creation management is a recipe-configured catalog of code-owned features and reviewed recipe-runner launchers. A recipe is data (a versioned T-Bone document in a shared protobuf-defined schema), authored in Flank, stored and enforced by Sirloin, rendered by Brisket through a fixed registry of trusted components, and compute-executed by Brain against an immutable workflow pin. Publishing a recipe is the normal way to reorder creation stages, change option catalogs, or repin a compatible Brain workflow. Adding a new management interaction or lifecycle requires a reviewed contract and consumer release. A recipe declares whether it produces a Real or Virtual character, matching the durable identity category stored on the character. Labels such as “VI”, “RI Initial”, and “RI Subsequent” remain recipe concepts rather than code branches or additional lifecycle variants; their creation-method provenance is the pinned recipe key, version, and digest. Six seedable reference recipe documents live in apps/flank/app/fixtures/character-recipes/: female and male onboarding/default RI routes and female/male VI routes. They are authoring and contract-test inputs, and the local/dev Flank seed publishes the recipes and their family in dependency order. Operators can still publish them manually. In either path, the Brain CHARACTER_COMPUTE workflow must be published before the Sirloin recipe. The Flank image packages fixture seeds, but its pre-deploy seed is disabled by default. Deployments must leave both FLANK_CHARACTER_RECIPE_SEED_ON_DEPLOY and BRAIN_CHARACTER_COMPUTE_FIXTURE_SEED_ENABLED unset in preview and production; the default-off Brain gate prevents the merge target’s inherited POC bootstrap. The recipe contract is forward-only while Characters V2 remains unreleased: old POC recipe documents and sessions are unsupported; there are no fallback fields, dual parsing paths, or migration code for them. Existing preview and local development data may be discarded operationally. Recipe fixtures pin fixed workflow UUIDs and digests, so manual provisioning must use the exact contract before recipe publication. The VI reference recipe starts character.provision when its final initial paginated answer is submitted. It reserves the creation charge (20 credits or the available first-character-free entitlement), captures it when the first preview set succeeds, and releases it on failure. Discarding after successful generation does not refund that charge or restore the free entitlement. The subscription paywall stays usable while provisioning runs; after authoritative subscription confirmation, a narrowly supported running-paywall transition opens the adjacent mirror reveal. Completion returns to the subscription stage, and ordinary account visibility normalization skips it for subscribed users. The reveal displays one character’s three generation references.

If initial provisioning fails while this subscription paywall is current, Sirloin records the failure and releases the reservation immediately, but keeps the session active on the paywall with its saved inputs and source history. A backend-owned deferredProvisioningFailure flag in the session result marks this case, so later regeneration failures that revisit the paywall are not mistaken for initial provisioning recovery. Normalization removes the flag atomically when leaving the paywall. After subscription is confirmed, normalization returns to that source for retry instead of advancing to the mirror. This shared rule also covers RI recipes placing provisioning before subscription; it does not change the paywall-first RI fixtures, photo validation, or adult/KYC flows. Back retains its history barrier; closing preserves the session, while explicit discard allows restarting. Expired executions with corrupt history retain the existing source-stage repair fallback. Sources: apps/sirloin/internal/pkg/storage/characterexecutioncommit.go and apps/sirloin/internal/pkg/storage/characterrecipecapabilities.go.

A recipe can opt into background photo validation before subscription by setting thenActionId and failureStageId on its creation entry’s UPLOAD action. Its linked AUTHORIZE action has the server-only CHAINED role. Neither field is required by existing recipes; management flows on the same recipe use their ordinary rules.

The supported topology is deliberately fixed: an unconditional unsubscribed upload entry, an actionless unsubscribed paywall, the ordinary upload retry stage, the ordinary photo-validation stage, and a processing reveal. The chained action must use the same workflow, bindings and catalog billing policy as the ordinary subscription-protected Continue action. Upload validation is unbilled. The creation flow cannot use an identity-selfie input, which would pin an adult identity and change the session’s routing rules. Sirloin validates these constraints before publication; Brisket checks the shape it needs to render and hides CHAINED controls. Entry → paywall → retry upload is positional: these three stages must be adjacent in that order. The same validator also checks pinned recipes at runtime, so future rule changes must preserve published shapes or explicitly version the rules.

An unsubscribed user’s upload moves directly to the paywall. Funding is checked at upload preparation and finalization, without reserving a slot or debiting credits. If no free slot or sufficient credits are available, submission returns the existing insufficient-credits response and the frontend opens its subscription dialog.

Accepted validation binds the photo and creates the provisioning execution in the same database transaction. The existing execution creator resolves inputs and reserves billing once. Any creation error rolls back the parent terminal commit; normal cause-based webhook and reaper error handling then applies. If a recovered parent still fails to create its child after the four-hour backstop deadline, the reaper attempts a failed terminal instead. This bound applies only to chain errors; settlement still requires a working database. A funding refusal detected before creation instead commits the accepted photo with the sole new session marker, photoFirstChainDeferred: {reason: "not_funded"}. It creates no child and reserves nothing. Successful pre-payment provisioning consumes the free slot or credits even when the customer does not subscribe.

State after subscription confirmation Destination
Validation or provisioning is running Processing reveal with the existing waiting animation
Provisioning succeeded Processing reveal
Validation declined or execution failed Existing photo-validation error screen; Change photo returns to the ordinary upload stage
Funding became insufficient before chaining Existing successful validation screen; Continue provisions normally

Failures of either linked execution settle on the paywall with the entry history preserved. This remains true if an earlier subscribed read already showed the reveal. A subscribed read normalizes that failure to the retry validation screen. Unsubscribed reads stay on the paywall. Stage normalization clears the funding marker when consumed. No second failure marker or separate recovery state machine is needed. Subscribed users starting a fresh session skip the hidden entry and paywall and follow the ordinary photo upload → validate → Continue flow.

If a subscription expires during unfinished creation, checkout temporarily holds the current stage behind a history barrier. Renewing restores that progress and its existing results without another upload, provisioning run, or reservation.

Chained executions add two nullable columns to the existing execution table: chain_parent_execution_id and dispatch_claimed_until. A 30-second claim at the shared Brain enqueue function prevents concurrent dispatchers from posting the same queued child. The queued reaper takeover checks the claim atomically. Ordinary executions do not claim and keep their existing fingerprints and dispatch behavior. The webhook and runtime-control dispatch any child ID returned by a successful terminal commit, including a replay. The reaper leaves recovered children queued so a slow Brain handoff cannot delay subsequent rows in its serial scan. A River task retries queued children every 15 seconds and on startup through the same dispatcher. It uses River priority 4, so higher-priority ready jobs are picked first; queue congestion can delay recovery. It drains waves of four with a 45-second admission budget, up to 100 attempts. An admitted dispatch can finish under its own 15-second timeout. Candidate selection rotates updated_at so skipped or failed attempts cannot monopolize later sweeps; the original created_at expiry and dispatch claims remain unchanged. Children older than the existing 30-minute grace remain the expiry backstop’s responsibility. The partial queued-child index uses (created_at, id) to filter by age; PostgreSQL sorts eligible rows by (updated_at, id) for retry ordering. Prebuild it with apps/sirloin/ops/character_chain_retry_index_concurrent.sql before deploying migration 186, following the Sirloin runbook.

This is a best-effort dispatch mechanism, not an exactly-once Brain guarantee. Response loss can duplicate Brain work, without duplicating the reservation. The webhook’s inline handoff can add up to 15 seconds after commit. A slow commit plus handoff can exceed Brain’s 30-second callback timeout and produce a warning even though the commit is durable; the sweep can recover an undispatched child. The retry task recovers missed handoffs without requiring a later session read. Lost callbacks depend on the existing reaper; its legacy output projection cannot recover the current validation workflow shape and can reject an accepted photo. Those platform reliability fixes remain separate. The retry task adds no table, special reaper commit mode, or new customer screen.

Deploy the nullable-column migration before Sirloin code, and frontend support before publishing an opted-in recipe. For rollback, restore the previous recipe for new sessions and retain compatible runtime support for existing pins, including completed sessions and characters; follow the rollback procedure. No existing recipe is changed automatically. An opted-in recipe can replace an existing family member on the same route. The family charge-path validator first validates the complete photo-first shape, then models successful chaining as a charged path to the reveal and failure or deferral as an uncharged path to the ordinary validation stage. The hidden child is visited only through its parent. The ordinary subscriber path is still checked, as are downstream double charges, uncharged completion and re-entry after provisioning. Other recipes keep the existing traversal. Verify the pinned workflow contract and live graph before publication. Sources: apps/sirloin/internal/pkg/storage/characterphotofirst.go, apps/sirloin/internal/pkg/storage/characterrecipefamilyresolution.go, apps/sirloin/internal/pkg/storage/characterphotofirstexecution.go, apps/sirloin/internal/app/services/characterexecutiondispatcher.go, apps/sirloin/internal/app/worker/retryqueuedcharacterchains.go, and apps/brisket/src/features/characters-v2/recipe/photo-first.ts.

In flows declaring grants.capabilityKey: "adult", the processing reveal keeps the animated hero and gallery photos blurred until the user taps “Tap to reveal photos”. The first tap reveals the whole gallery; later taps open fullscreen. Animation completion, skipping, and reduced motion do not reveal the photos. Consent lasts for the current session, stage, and asset set, survives refreshed image URLs, and resets when any of those identities change or the stage remounts. This uses the flow’s existing recipe metadata and needs no additional recipe prop. Sources: apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stage-renderer/index.tsx and apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/processing-reveal/content/index.tsx.

“Change appearance” opens a separate paginated stage with identical questions and runs character.prepare-options. Each character includes five regenerations; each later regeneration costs 3 credits. Failed attempts release their reservation and do not consume the allowance. Session responses expose authoritative billing allowances, counting reserved and captured operations. Brisket shows the remaining free regenerations or next credit cost inside “Change appearance” and repeats it on the last regeneration question before submission. Both displays opt in through the action’s props.billingSubtitleActionId: the reveal button references regenerate-look, and the questionnaire’s primary action references itself. The reference selects the billing source; it does not execute that action or change navigation. Unconfigured actions show no billing subtitle. Missing server allowance data hides the message rather than assuming a free use.

Currently, the reference must resolve within the same flow to an executable character.prepare-options action with an included-use billing policy. Navigation buttons must reference their destination stage’s primary action, while direct execution buttons reference themselves. Recipe validation rejects unsupported targets and placements. Other billing subtitle types can be added under the same property when their authoritative pricing data is supported.

The optional “Re-roll” action uses props.placement: "gallery-top-right" on a processing-reveal stage. The slot accepts one CHOICE action, with its normal label and optional recipe icon (refresh supplies the two-arrow symbol). Placed actions are omitted from the footer. Unconfigured recipes retain their existing controls. The action can optionally provide confirmationDialog with title, description, confirmLabel, and cancelLabel; this is independent of the existing KYC reset confirmation. Opening or canceling the dialog executes nothing. Its billing information comes from billingSubtitleActionId and is shown on a separate line below the description.

The VI fixture’s reroll-look action shares the prepare-look workflow and regenerate-options billing policy with questionnaire regeneration, so both consume the same allowance. Its explicit inputValuesSource: "session" uses saved answers for availability, validation, and submission, ignoring unfinished local questionnaire edits. This input source is optional and supported only for an executable gallery action; other actions keep their existing draft behavior. Confirmation shows a loading button and stays open while the existing action controller submits. Once submission settles, the confirmation and full-screen gallery close so the mirror, error, or checkout can be visible.

Sources: apps/brisket/src/features/characters-v2/views/creation/recipe-runner/regeneration-billing.ts and apps/sirloin/internal/app/services/characterrecipepropsvalidation.go, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/review/gallery-action/index.tsx, and apps/brisket/src/features/characters-v2/views/creation/recipe-runner/hooks/use-recipe-actions.ts.

“Looks great” commits those exact staged references through an unbilled character.complete workflow and opens success with an Explore destination. If regeneration fails, the creation session restores the appearance answers from the execution that produced its retained generation references, so accepting an older gallery cannot commit new answers that never generated successfully. RI recipes and their workflow pins remain unchanged.

The VI preparation pin aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaa0077 is a POC fixture: its reference materializer produces deterministic placeholder images, not appearance-driven provider images. Publish this Brain pin before seeding the updated VI recipe. The discarded candidate-selection workflows are no longer seeded. Their IDs are retired by the development fixture seeder; the shared asset-picker renderer remains available for other recipe designs.

The VI foundation passes all questionnaire answers and the recipe’s fixed gender to provisioning and regeneration under the workflow’s values input. Real appearance-driven generation and durable image storage are deferred; the POC materializer emits placeholder bytes without uploading their objects.

Generation projection trusts the character’s current committed generation references when both the stored character and its pinned recipe are virtual. The account-scoped live bindings must provide exactly one available image with ordinal zero and a nonempty object key for each required reference role. VI reference eligibility does not require recursive ancestry validation; missing, unavailable, cyclic, or deep ancestry does not invalidate an otherwise valid current VI reference. Recipe authors define the workflows that produce those references. Existing upload/stock acceptance and RI lineage restrictions remain unchanged, and reference eligibility does not itself grant adult access. RI lineage reads each asset’s root_asset_ids, written when the asset is inserted, so a chain of derived references has no depth limit. The checks are in apps/sirloin/internal/pkg/storage/charactergenerationprojection.go, apps/sirloin/internal/pkg/storage/characterprojection.go, and apps/sirloin/internal/pkg/storage/characterassetlineage.go. Projection and listing coverage lives in apps/sirloin/internal/pkg/storage/charactergenerationprojection_test.go and apps/sirloin/internal/app/services/characters/listcharactersvi_test.go.

Creation is deliberately open-ended within the trusted stage registry, so a new creation recipe that mixes questionnaire and upload stages should require no new enum, table, RPC, or renderer. Management is intentionally narrower: Name, Likeness, and Settings use code-owned layouts; CAPABILITY launches a granting flow; and FLOW launches a standalone, non-granting flow through the same reviewed recipe runner. A management FLOW cannot reference or transition into the creation flow. When a standalone flow contains an account-visibility gate, it must also keep the remaining stages in that flow instead of transitioning to another flow, so later eligibility changes can return to the prerequisite. Creation flows containing NSFW_UNSUBSCRIBED_ONLY or KYC_UNVERIFIED_ONLY cannot leave their flow, and no account-gated capability-granting flow can leave its flow. This keeps the branch’s new adult prerequisites recheckable without changing the existing generic UNSUBSCRIBED_ONLY creation transition into a granting flow.

Choice options render in their recipe-authored display order. A field’s defaultValue selects and marks the initial option without moving it to the front; this keeps ordered scales such as adult anatomy sizes smallest-to-largest.

The four RI fixtures use standalone FLOW sections for Name, Settings, and Likeness. The user-facing Accuracy card is the Likeness section (id: likeness, flowKey: section-likeness). Each starts from the character’s saved values and references. Name and Settings save their form into a new character state version. Likeness stages uploads in the session, returns each accepted photo to its dataset overview, and commits the references only when the customer presses Save. Resume reopens the session with its staged changes. Discarding the session preserves the saved character. Standard references are always shown. In standalone Likeness management, the adult dataset group requires the character’s adult capability. During adult enrollment, server-resolved action permissions allow the group before the capability is granted; subscription, KYC, and action checks still apply.

Female and male adult enrollment use the same generated-look reveal choices: the customer may accept the result, return to anatomy settings and regenerate, or open the adult likeness overview to upload a manual dataset. Standard reference-photo checks and repairs stay before nudification and are not exposed from that reveal. After successful enablement, the completion screen returns to the general Characters roster rather than opening that character’s management view. Complete status stages express that navigation with props.completionTarget. { "kind": "path", "path": "/characters" } opens a validated app-relative path, while { "kind": "character" } invokes the runtime completion callback and opens management for the session’s character. Authored paths must begin with one / and cannot contain a host, backslash, or control character. The legacy completionDestination property remains readable during migration, but a stage cannot author both properties.

A creation flow can hand a newly saved character to an optional management flow (apps/sirloin/internal/pkg/storage/charactersessioncompletion.go):

  • The creation flow’s actionless complete status stage declares props.completionTarget: { "kind": "managementFlow", "flowKey": "character-improvement" }. The target accepts exactly these two keys. It must name a standalone management FLOW without capability grants. Other flows and stages cannot declare it.
  • Completing creation saves the character and records the pending destination in the same transaction. The destination comes from the source session’s pinned recipe.
  • Each source has one deterministic destination session ID. Retries, double clicks, and reloads return that session with its current progress or terminal state instead of opening another one.
  • Sirloin starts the destination after the source commits. If startup is interrupted, the next session-view read retries it, so an open browser recovers through ordinary polling (apps/sirloin/internal/pkg/storage/charactersession.go). Cancelled requests and transient database errors leave the handoff pending for that retry. New startup attempts are allowed for 30 seconds from the creation session’s persisted completed_at. The next attempt at or after that deadline records failure and uses the same saved-character fallback below. An already-started destination remains resumable regardless of its age. This bounds retries; it does not interrupt an in-flight database request.
  • Any other startup failure, such as a deleted or no longer available character or a non-transient database error, is recorded on the completed source and ends the handoff. The saved character stays available, and Brisket opens its management page with “Your character is saved.” (apps/brisket/src/features/characters-v2/views/creation/index.tsx). A hidden destination has no menu entry, so after a recorded failure that character cannot reach it again; content also exposed through visible sections stays reachable.
  • A session the customer starts after creation completes supersedes a pending handoff, which then ends through the same recorded failure.
  • A management section may set hidden: true to omit its menu card while keeping its flow startable and resumable. Only FLOW sections can be hidden. Optional improvement sections should leave discardOnExit false to preserve progress when closed (apps/brisket/src/features/characters-v2/views/management/management-content/index.tsx).

Before publishing recipes using hidden or completionTarget.kind: "managementFlow", finish deploying the supporting Sirloin API/workers and Brisket, and deploy Flank with the updated bindings for authoring. Older Sirloin instances strictly decode stored recipe JSON and reject unknown fields (apps/sirloin/internal/pkg/storage/characterrecipe.go); older completion-target validators reject managementFlow. Follow the recipe capability rollout requirement for existing browser tabs and rollback compatibility with pinned recipes.

The four RI fixtures finish Name, Settings, and Likeness management directly from their Save action, without a separate success stage. The action sets props.completionTarget: { "kind": "character" } and uses NEXT from the last stage in the flow. A { "kind": "path", "path": "/characters" } target can instead select a validated app-relative URL. Once the server reports the session as COMPLETED, Brisket navigates immediately and refreshes cached data in the background. An optional action props.completionToast supplies a nonblank success message of at most 240 UTF-8 bytes. Without the toast, navigation is silent. These saves use "Changes saved". The same options work on any role of a terminal SAVE or workflow-backed EXECUTE, AUTHORIZE, or UPLOAD action. They require a standalone management flow and NEXT from its last stage. Workflow actions wait for successful execution before completing the session. Sirloin records the completing action’s flow, stage, and action IDs in result.completionAction; Brisket resolves that exact pinned action’s options, including after a reload. A different action’s redirect or toast is never used as a fallback. Likeness keeps reference-assets as its entry stage and places it last so its Save action completes the flow; photo uploads and validations navigate by explicit stage IDs or back transitions. Creation, adult enablement, and recipes without this action option keep their existing completion behavior.

An SFW reference upload may declare props.adultWorkflowId alongside its standard workflowId. Sirloin selects the alternate only for an adult-enabled character; the alternate must validate the same reference slot against the verified identity. This lets standard characters keep quality-only validation while adult characters retain their identity checks. Execution completion follows the workflow pinned at dispatch. Withholding adult access does not remove this identity requirement; standard reference replacements still use the identity-checking workflow. Saving an existing adult character also rejects changed identity-bearing references without an attested source for that character’s identity; untouched references are preserved.

Reusable creation forms express edit-and-return behavior on their local action with transition.return_to_caller_if_present; when no caller exists, the action uses its authored NEXT fallback. Customer creation cards come from account-resolved recipe families. Sirloin derives their displayed credits from the selected recipe’s executable provisioning billing policy. An available account-level first-character allocation overrides that price to zero for every provisioning recipe, including a default recipe selected when no onboarding member exists. Families do not control first-free eligibility. Separately, whether the account has ever had a character with status CHARACTER_STATUS_AVAILABLE and a non-null available_at selects onboarding versus default family members. Recipe-local catalog pricing remains part of recipe validation and pinned execution rather than customer discovery.

This records a refactor of unshipped work. The full design rationale, data model, and test matrix live in the FOXY-694 / FOXY-468 materials; this page is the architecture decision record for the engine shape. Compute-graph storage is a narrow delta to the Linear “contracts live in sirloin” ADR — see Character compute graphs pin by Brain workflow UUID.

Concern Lives in Notes
Presentation (stages, fields, actions), billing policies, capability projection Sirloin canonical_recipe Single customer-facing SoT; Brisket never reads Brain for render.
Execute binding pin Sirloin CharacterWorkflow brain_workflow_id (published Brain row UUID) + graph_digest + contract projection (purpose, inputBindings, outputSchema, assetOutputs). No embedded graph.
Full node/edge graph body Brain workflow table Purpose CHARACTER_COMPUTE; node availability via registry allowedPurposes (omit = all purposes); SoT for Bull execution.
Ship gate Two-step Publishing a Brain workflow does not change production funnels. Only a Sirloin recipe publish that repins the UUID ships compute. Run never follows “latest”. Flank character-recipe editor detects stale pins and offers a local JSON bump; publishing a CHARACTER_COMPUTE workflow surfaces which recipes still pin that name (informational — no auto-repin).

Recipe publish and rollback fail-closed checks: local definition validation, Brain ValidateCharacterRecipe, then live GetWorkflowPinByID (PUBLISHED + CHARACTER_COMPUTE + digest match). Pins stay in the master JSON only; with a few dozen active recipes there is no separate reverse-index table.

presentation.*.localData is client-owned metadata for short-lived IndexedDB values. Sirloin still receives the action request and remains the only authority that selects the next visible stage. A local value is never copied into the server session value bag and cannot be used by showWhen, availableWhen, entry-stage resolution, transitions, management visibility, required-field rules, billing, or workflow inputs. Publication rejects a condition path that names a local-data key. Any value that must affect server behavior must be declared as a normal recipe field and submitted through an authoritative action.

A recipe family is stored and published independently from recipes. It owns the single title, description, media, badges, sort order, and optional closed selectors that Brisket presents as one creation choice. Each member maps a selector tuple and one of two roles to a stable published recipe route:

  • ONBOARDING is preferred when the account has never had a character with status CHARACTER_STATUS_AVAILABLE and available_at set.
  • DEFAULT is required. It is selected after the account has ever had a character with status CHARACTER_STATUS_AVAILABLE and available_at set, and is also the fallback when an onboarding member is not authored.

Publication requires at least one default member for every advertised selector combination; onboarding members are optional. Each role and selector combination is an allocation group that may contain multiple distinct recipe routes with positive weights totaling exactly 10,000 basis points. It also checks that referenced recipes are published, customer-visible, produce the family’s character type, and have consistent catalog and executable billing. When a family advertises a gender selector, each selected route must resolve to a recipe whose immutable genderConstraint exactly matches that member’s gender value; an unspecified recipe cannot be presented as female- or male-locked. A routable recipe may offer alternative provisioning actions, but no reachable creation path may cross more than one; the provisioning transition must also create a Back-history barrier so one catalog quote cannot be charged twice. First-character treatment is an account-global billing allocation and therefore does not participate in member compatibility. The checked-in RI family exposes one female/male selector. Its four members cover onboarding/default for both values, and every member recipe declares the matching immutable gender constraint.

Family creation prices come exclusively from the selected recipe’s executable provisioning billing. Member recipes must have consistent catalog and executable billing, but may have different prices. Recipes can publish price changes independently; the catalog quotes each selector combination from the recipe selected for that account, and session start checks the accepted quote. The account’s first-character-free allocation continues to apply, including its normal reservation and settlement lifecycle.

Sessions pin the selected recipe definition. Billing operations record the reserved amount for capture, refund, and idempotent replay. Open-session recipe migration skips family creations if the target changes their character type, gender constraint, creation flow, or provisioning price. Same-price recipe fixes can still migrate. Direct recipe starts, regeneration, upgrades, and other actions also use recipe pricing (apps/sirloin/internal/pkg/storage/characterbilling.go).

If credit reservation rejects an action for insufficient credits, Sirloin returns the effective amount it attempted to charge. Brisket includes that cost in the error and toast, opens its existing top-up flow, and keeps the form available for retry. Action button labels stay recipe-authored. Prices are not fetched on session reads for this error message.

The customer catalog returns families only; published recipes outside a family stay available to trusted direct-route callers but are not discoverable in Brisket. At catalog time, Sirloin resolves every complete selector tuple for the account. A family’s optional maxCharactersPerAccount limits occupied character slots across all of its recipes, genders, and roles. Omitted means Unlimited; an explicit value must be a positive integer. Occupied slots are completed creation sessions attributed to the family’s stable key whose character has not been deleted. Deleting a character frees its slot; hiding it does not. Direct recipe creations without saved family provenance do not occupy a family slot. Deletion does not reset historical onboarding eligibility or first-character billing treatment.

The currently published family limit applies to every account. Publishing a change from five to ten allows five additional slots for an account occupying five; publishing Unlimited removes the family cap. Lowering the limit preserves existing characters and prevents new starts until occupancy is below the limit. Draft edits have no effect on the live allowance. Existing sessions retain their pinned recipe and can resume after a limit change.

Sirloin marks every selector tuple unavailable when the family is full. Optional per-recipe lifetime completion limits remain supported and can additionally disable individual tuples, but the checked-in creation recipes do not set those limits. Family occupancy is counted in one grouped query for finite families; Unlimited families do not require this count. This catalog snapshot is advisory: session start repeats quota checks transactionally. Brisket refreshes family availability after deleting a character. At session start, Sirloin re-resolves eligibility, selector values, route publication, family revision, and displayed credits under the existing per-account lock. The session records family key/digest/revision/role/selections as provenance and separately pins the immutable resolved recipe version and digest. This keeps retries stable without copying recipe execution concerns into the family.

Families retain one published definition and one optional draft. Publishing replaces the published definition; there is no family version history, historical lookup, or rollback. The editor’s ListCharacterRecipeFamilies response includes both current definitions and validates them separately because their publication requirements differ. Revisions protect concurrent edits, and event snapshots preserve idempotent request replay without exposing an authoring history (apps/sirloin/internal/app/services/characterrecipefamilyservices.go, apps/sirloin/internal/pkg/storage/characterrecipefamily.go).

The customer resolved catalog omits a family with malformed canonical family or member data and logs the affected family key, allowing healthy siblings to remain available. Diagnostic requests remain strict; database failures and missing referenced documents or recipe versions still fail the request. Session start retains full-family validation (apps/sirloin/internal/pkg/storage/characterrecipefamilyresolution.go).

Family fixtures are explicit authoring inputs. Flank’s seed:character-recipes command publishes the repo-owned recipes first and then their dependent family fixtures. The standalone seed:character-recipe-families command remains available for deliberate family-only repair; replacement of an existing published family requires its explicit replacement flag.

Recipe publication and rollback fail if the target version would invalidate an active family, and the admin error names the blocking family. Publish a disabled definition of that family, retry the recipe change, then update and re-enable the family with compatible members. Do not bypass the dependency check with direct database changes.

Do not enable Brisket family discovery before the first family is live. Roll out in this order. New recipe capabilities must also satisfy the Brisket support requirement before recipes using them become available:

Coordinate the Sirloin and Brisket deployments: this branch moves CharacterSession.session_id, flow_key and verification_selfie_url from protobuf tags 17–19 to 23–25. Family provenance uses family_key at tag 17, family_digest at 19, family_revision at 20, family_role at 21, and family_selections_json at 22; tag 18 is unused. Old and new readers can misinterpret those fields during a mixed-version rollout. Pause V2 customer and QA session traffic until both services run matching generated contracts; disabling the feature flag alone does not disable staff QA access. The repository persists sessions in SQL columns and event replay snapshots as JSON (charactersession.go and characterevent.go under apps/sirloin/internal/pkg/storage/); the reviewed queue paths do not serialize protobuf CharacterSession. This is a code inspection, not a live queue audit. Before rollout, confirm deployed integrations have no queued binary sessions; if they do, drain or migrate those payloads with matching readers first. SQL rows and JSON replay snapshots do not need conversion for tag renumbering.

  1. Deploy Sirloin with migration 163 and the family gRPC methods, including ListResolvedCharacterRecipeFamilies, before deploying Brisket or Flank clients that call them. Deploy regenerated consumers with CharacterRecipeGenderConstraint support before enabling Flank publication or seeding of gender-routed families; older consumers treat the new field as unspecified and cannot preserve the family gender constraint. Before seeding VI recipes, complete deployment of Sirloin, Brain, Brisket, and Flank authoring tooling with CHARACTER_MANAGEMENT_FEATURE_KIND_COMING_SOON support from proto/tbone/v1/character_recipe.proto. Older consumers can reject this enum; do not publish the VI fixtures while those consumers are still serving.
  2. From apps/flank, publish the checked-in recipes and dependent RI family with SIRLOIN_GRPC_URL=<sirloin-grpc-url> pnpm seed:character-recipes. The combined seed uses CHARACTER_RECIPE_SEED_REPLACE (default true) for recipes and passes that decision to families unless CHARACTER_RECIPE_FAMILY_SEED_REPLACE is set explicitly. Direct pnpm seed:character-recipe-families defaults its replacement flag to false; set it to true only for a reviewed family-only replacement. Seed audit actors default to flank:recipe-seed and flank:recipe-family-seed and can be overridden with the corresponding FLANK_CHARACTER_RECIPE_SEED_ACTOR_ID and FLANK_CHARACTER_RECIPE_FAMILY_SEED_ACTOR_ID variables.
  3. Verify the initial-ri-sfw, digital-twin-sfw, initial-ri-male-sfw, and digital-twin-male-sfw recipe routes were published.
  4. Verify routing and pricing independently. Fetch the resolved family catalog as both a new test account and an account with a historical CHARACTER_STATUS_AVAILABLE character to confirm onboarding/default selection. Separately test accounts with available and consumed first-character billing allocations to confirm free/credit quotes; character history does not set price.
  5. Through staff QA, complete both female and male onboarding and default flows, verify terminal character projection, and generate media from each resulting character. Returning accounts are now routed to DEFAULT automatically; they cannot choose the onboarding recipe to work around a broken default workflow. The female and male default routes must collect a required frontal face, then offer optional full-body and angled-body uploads before provisioning. Their upload-backed validation workflows preserve supplied references, while the provisioning workflow derives only skipped references from the frontal face and generates the preview set. The initial routes retain their one-photo provisioning flow. Publish the exact reviewed workflow pins required by the POC publication preflight; the checked-in behavior is in apps/flank/app/fixtures/character-recipes/digital-twin-sfw.json, apps/flank/app/fixtures/character-recipes/digital-twin-male-sfw.json, and apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts.
  6. Only after those checks pass, enable or deploy Brisket’s family-only discovery.

If the gate fails, keep Brisket discovery disabled and repair or roll back the family/recipe publication in Flank. Do not fall back to exposing every published recipe, because published-but-unlisted recipes are intentionally non-discoverable.

The JSON fixtures are the protobuf contract written in JSON form. At the top level:

Property Meaning
schemaVersion and key Identify the contract version and stable recipe route.
characterType Declares the durable Real/Virtual identity category the recipe creates; it does not identify a particular creation method.
genderConstraint Optionally declares that the immutable recipe version provisions only a female or male character. Gender-routed family members require an exact match.
catalog Supplies the card title, description, media, badges, and displayed billing policy. It does not start creation or charge by itself.
presentation.creationFlowKey and presentation.flows Name the creation flow and contain every named flow’s entry stage, stage definitions, grants, and browser-local data declarations.
presentation.management Selects reviewed post-creation features and launchers. featureKind chooses Name, Likeness, Settings, a granting CAPABILITY flow, or a standalone non-granting FLOW.
workflows Maps recipe-local workflow ids to immutable Brain pins, input/output schemas, and asset roles. Brisket receives only the customer-safe projection.
billingPolicies Defines the recipe-local prices that actions may reference.
policy Carries protected product and safety constraints validated by Sirloin. Only an allowlisted customer-safe projection reaches Brisket.

A stage is one screen-sized unit of the flow. Its kind and variant choose a reviewed renderer; title, description, and validated props supply copy and renderer options; fields describe inputs; content describes authored display blocks; and actions describe the controls the customer can invoke. An action is not just a button label: its id, kind, role, bindings, entitlement, running stage, and transition are the server-validated instruction for what happens next. The reference fixtures show the complete object shape (proto/tbone/v1/character_recipe.proto, apps/flank/app/fixtures/character-recipes/initial-vi-sfw.json, apps/flank/app/fixtures/character-recipes/initial-ri-sfw.json).

Recipe-runner stages declare stageVisibility. EVERYONE belongs to every session; UNSUBSCRIBED_ONLY is visible without any active paid subscription; NSFW_UNSUBSCRIBED_ONLY is visible without an active full-access (18+) subscription; and KYC_UNVERIFIED_ONLY is visible until the current account KYC is approved and has the identification and selfie required by character workflows (or an explicit Strip bypass). Historical approval alone still satisfies legacy age-verification access, but cannot skip this V2 identity prerequisite. The matching reviewed renderers are confirmation.paywall, confirmation.full-access-paywall, and confirmation.kyc-verification. Sirloin resolves these account-owned conditions on Start, Get, and before and after actions, for every recipe-runner flow: creation, capability-granting, and standalone management FLOW. Eligibility can therefore move a session forward after checkout or verification, or back to an earlier prerequisite in the same flow if account eligibility later changes. Skipped account gates are removed from Back history. Brisket does not infer this routing: checkout and KYC renderers start their external operation and refresh the session, while Sirloin returns the first eligible stage. Action-level ACTIVE_SUBSCRIPTION remains authorization for protected workflow or billing operations and does not select checkout UI. Every recipe-runner stage must declare stageVisibility; unspecified values or an account-gated stage without a later unconditional EVERYONE successor fail closed. Account-gated stages cannot set backBarrier, and none of their actions can set historyBarrier, because eligibility normalization removes those stages from Back history. Adult-enablement KYC uses the dedicated CHARACTER_ADULT_ENABLEMENT purpose so successful completion does not create the legacy gift character. The purpose is stored on the account KYC record and retained by Strip sync to keep that gift decision consistent.

KYC start, approval reuse, callback recovery, cleanup and shared reset eligibility follow ordinary KYC behavior for every purpose. Starting while awaiting creates a new attempt; existing approvals and legacy character-only approvals are reused. Late callbacks use ordinary external-reference recovery, including after a reset or a newer approval. There is no special superseded-adult approval path and no AutomaticCleanup marker. The purpose migration only extends the allowed values; it does not backfill or rewrite historical KYC rows.

V2 still requires usable current identity evidence at workflow dispatch and adult capability grant. Reusing an old approval without a selfie therefore does not satisfy the V2 stage. The V2 UI confirms the session for a bounded time, then offers the existing reset when ordinary reset eligibility permits it, or explains that setup cannot continue. Shared KYC does not bypass approval reuse or reset rules. Every intentional reset of approved KYC uses one account-wide lifetime allowance: three customer resets, regardless of character, session or verification purpose. The recipe effect and the ordinary account reset endpoint share this policy. Creating or deleting characters does not restore the allowance. Automatic retries of unfinished verification do not consume it.

The existing characters.character_events journal records successful resets as account_selfie_reset events under an account aggregate. Their count is the account lifetime usage. The account lock serializes quota checks, and the reset and history entry commit in one transaction. Failed transactions and replayed recipe actions or support requests cannot consume another reset. The ordinary account endpoint requires a currently approved verification on each call; an immediate duplicate therefore fails, but a new call after reapproval is a new reset. Existing character counters are not quota sources. No new table, migration or historical backfill is needed. Only new account_selfie_reset events consume the allowance; old character reset events and KYC history are not counted. The journal’s existing unique account/request and aggregate/revision constraints protect request replay and sequence numbering. Its aggregate index supports the account-specific count. Reset events must be retained for the account lifetime; deleting a character does not delete them. Deploy Sirloin first and drain all old Sirloin instances before deploying Strip or Brisket. Mixed Sirloin versions can still make uncounted resets, so the quota is not enforced fleet-wide until every old instance is retired. Strip requires the new StripResetUserSelfie RPC, and Brisket uses kycResetLimitReached for quota feedback; do not deploy either client before that server rollout completes.

kycResetEligible is true only when the customer can reset now. kycResetLimitReached is true when the lifetime quota is the only blocker. Brisket keeps Reset visible in that second state; clicking explains that the reset limit has been reached and directs the customer to support without submitting a reset. Mutation errors also distinguish quota exhaustion so stale clients receive the same copy. Support can perform an additional reset from user details in Strip. Its audit row records the reviewer, reason and account count change atomically. A support reset increases lifetime usage and does not replenish the customer’s allowance.

Both customer and support resets retain the existing eligibility checks: current approved KYC and no qualifying successful NSFW media. The media query checks available, nondeleted NSFW media on live characters marked is_nsfw or backed by a recipe (recipe_version_id IS NOT NULL). Deleting media or characters can remove that block but never restores reset allowance. Being recipe-backed or enabling adult capability alone does not prevent a reset.

The V2 workflow’s own reset action clears its session identity pin and refuses to reset during a running identity execution; those rules are local to that action, not selected by KYC purpose or applied to shared account usage/reset APIs. Normalizing an active V2 session with an identity pin rechecks current KYC. If verification is no longer valid, normalization clears the old pin even when an earlier subscription prerequisite is shown or the current stage stays unchanged. A new verification can then supply a new selfie; existing identity-attestation checks still prevent old likeness results from granting capability for the new identity. Reset counting does not change character adult approvals, identity anchors, capability layers or identity attestations. See apps/sirloin/internal/pkg/storage/accountselfiereset.go and the Strip support procedure.

Rollback requires coordinating Sirloin, Strip and Brisket. Preserve the reset journal events and audit records; reverting the quota code returns resets to the existing account eligibility rules.

Layer Owns Does not own
T-Bone (authored in Flank) Creation stage graph and order; trusted component keys and props; field, option, and dataset definitions; action bindings to recipe-local workflow ids; and bounded management feature configuration. Character compute graphs are authored as Brain workflows with purpose CHARACTER_COMPUTE. Physical storage, authorization, billing, the protected safety floor, or new management interaction models.
Sirloin Physical durability and protected invariants: immutable recipe publication and routing, account-scoped sessions and characters, OCC and idempotent replay, billing (reserve, capture, release, refund, first-free), execution rows and leases/fences for session progress, asset provenance, capability-projected reads, and code-owned safety filtering. Dispatches character EXECUTE via Brain Beef execute-by-id and reconciles completion on /webhooks/brain/character-completion. Recipe questionnaire contents; full compute graph body; Generation / media-completion path.
Brisket Presentation only: a generic recipe-stage runner plus code-owned Name, Likeness, and Settings management components; accessibility, theming, and clients with stable request IDs. Unknown keys or malformed props fail closed into a stable unsupported-recipe state. Any authoritative state; recipes select trusted components and reviewed management launchers but cannot inject code.
Brain Compute: versioned CHARACTER_COMPUTE workflows, per-node allowedPurposes at publish/palette, Bull WorkflowExecution / NodeExecution, start-by-UUID+digest, character-completion webhook (not media-completion). Recipe contract validation over gRPC remains available for publish. Character, session, asset, billing, or recipe-route state.

How Brisket turns the object into a screen

Section titled “How Brisket turns the object into a screen”

The family catalog contains display, selector, pricing, and availability metadata; Brisket does not validate every member’s renderability before displaying a family. The recipe checks below apply when loading a session or character projection.

  1. Brisket receives the customer-safe recipe projection with the pinned session or character projection. It decodes the JSON strictly as CharacterRecipe; unknown fields and invalid protobuf values are rejected (parseCharacterRecipe in apps/brisket/src/features/characters-v2/recipe/parse-character-recipe.ts).
  2. isRenderableCharacterRecipe checks the browser contract before React sees it: unique ids and keys, safe media URLs, valid local data, known renderers, compatible fields, and action shapes that the selected creation or management renderer can actually submit (apps/brisket/src/features/characters-v2/recipe/validate-character-recipe.ts:41-749).
  3. Sirloin omits logically unroutable families without hiding healthy siblings, and Brisket omits family cards that lack required catalog media. A bad pinned recipe produces a stable “Creation unavailable” screen and performs no action or billing (ListResolvedCharacterRecipeFamilies and characterRecipeFamilyCatalogSkips in apps/sirloin/internal/pkg/storage/characterrecipefamilyresolution.go, useRoster in apps/brisket/src/features/characters-v2/views/roster/hooks/use-roster.ts, apps/brisket/src/features/characters-v2/views/creation/index.tsx:29-59).
  4. The server’s currentStageId selects the stage. StageRenderer maps the reviewed kind/variant to its view and passes the recipe data, draft values, authoritative action availability, upload helpers, and navigation callbacks into that view (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/index.tsx:52-164, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stage-renderer/index.tsx:69-312).
  5. The view may arrange its buttons, fields, and media freely, but it does not invent progression. It submits the authored action id plus the input shape that action requires. Sirloin resolves that id from the pinned recipe, checks the expected session revision and availability, and returns the next authoritative stage.

Brisket uses the URL section id to select a recipe management section and dispatches featureKind. A CAPABILITY or FLOW management card starts its referenced named flow in the generic recipe runner. The four canonical RI recipes author Name, Accuracy, and Settings as discardable FLOW sessions: Name and Settings complete from their terminal Save action, while Accuracy lets the user upload and validate each supported reference slot before returning to the photo overview (apps/flank/app/fixtures/character-recipes/initial-ri-sfw.json, apps/flank/app/fixtures/character-recipes/digital-twin-sfw.json, apps/flank/app/fixtures/character-recipes/initial-ri-male-sfw.json, and apps/flank/app/fixtures/character-recipes/digital-twin-male-sfw.json). After the adult capability is granted, Accuracy also exposes each recipe’s adult reference slots. Female recipes expose their six-slot adult dataset. Male recipes expose face and chest, full body, angled body, penis close-up, and anus close-up slots. Male adult images may originate from the adult-enablement generation flow or replaceable manual references. Direct Name, Likeness, and Settings feature kinds remain supported for recipes that intentionally use their dedicated components, although direct Likeness is visual-only in the current milestone. Launcher section ids are canonicalized back to the management overview rather than rendered as empty focused panels (apps/brisket/src/features/characters-v2/views/management/management-content/index.tsx).

When another character occupies the current verified identity’s adult slot, management keeps the 18+ launcher visible but disabled, with a short explanation that 18+ is already enabled on another Real character. Sirloin checks the same slot before creating an adult-enablement session, so stale management pages cannot start a flow that will fail at the final grant. Deleting the occupying character frees the slot; ordinary creation and unrelated management flows remain available. The final grant and database uniqueness constraint still enforce the rule. Roster cards show an 18+ badge beside their type label only when the adult capability is usable, using the server capability flag rather than the reserved identity slot.

While a management flow starts, its launcher shows a spinner and the other feature buttons are disabled. On the roster, an unfinished management session attaches to the existing character card, preserving its saved name and portrait. Its Continue label uses the matching management section title from the session’s pinned recipe and flow key. Matching uses the full roster before search or type filters, so filtering cannot create a duplicate Draft card. Creation sessions retain their separate Draft presentation. Discarding management work removes the session’s unfinished changes, not the saved character; the confirmation explains that distinction.

Successful starts immediately update the local session and roster caches, then refresh server data. Session completion and discard remove the matching pending continuation and refresh the character lists. These are Brisket presentation and cache updates; session ownership and lifecycle remain authoritative in Sirloin.

Sources: apps/brisket/src/features/characters-v2/views/management/hooks/use-character-management.ts, apps/brisket/src/features/characters-v2/views/roster/roster-content/index.tsx, apps/brisket/src/features/characters-v2/views/roster/hooks/use-roster.ts, and apps/brisket/src/features/characters-v2/views/creation/hooks/use-character-session.ts.

For a visual redesign, change the relevant stage view and its shared presentation primitives. Do not add transition or billing logic to the component. If a design needs a genuinely new renderer contract or action shape, add it to both Sirloin’s publication validation and Brisket’s renderability validation; this parity prevents a recipe from publishing successfully and then failing only in the browser (apps/sirloin/internal/app/services/characterrecipevalidation.go:603-910, apps/sirloin/internal/app/services/characterrecipecapabilityvalidation.go:232-693, apps/brisket/src/features/characters-v2/recipe/validate-character-recipe.ts:41-749).

Presentation media slots (catalog image, preview video, content and option media, and the exampleUrl/exampleUrls props) accept a first-party path such as /characters-v2/RI-video.mp4, served from apps/brisket/public/, an absolute HTTPS URL, or a storage-media ref storage-media:recipe-media/<path>. Only the ref is signed and only its object is checked at publication. The ref names exactly one bucket object: its key is the part after storage-media:, and only keys under recipe-media/ are accepted (CharacterMediaURLApproved and ParseStorageMediaRef in apps/sirloin/internal/pkg/storage/characterrecipefamilyresolution.go).

  • Storage: Sirloin stores, validates, pins, and returns refs unchanged. That covers editor reads, customer reads, and session snapshots, so a signed URL is never written back by Flank or passed to Brain.
  • Publication: publishing or rolling back a recipe, and publishing a family, fails with storage_media_not_found at the ref’s JSON path when its object does not exist. Draft saves are not checked (missingCharacterRecipeStorageMedia in apps/sirloin/internal/app/services/characterrecipemediaservices.go).
  • Signing: SignCharacterRecipeMedia signs up to 256 refs per call through the object store’s HMAC CDN URL. It reuses that store’s 72h cache and omits invalid refs instead of failing the batch.
  • Brisket: the charactersV2Procedure and charactersV2QaProcedure middleware replaces every whole-string ref in a response with a signed URL before it reaches the browser. Brisket’s validator therefore only ever sees https:// URLs or first-party paths. A ref that cannot be signed (RPC error, 2s timeout, or omitted from the response) becomes /characters-v2/storage-media-unavailable.jpg, a path with no file behind it, and is reported through structured error logs. The recipe stays renderable, the browser gets a 404, and the media component treats it as broken media (resolveStorageMedia in apps/brisket/src/server/api/storage-media.ts).

A signed URL can expire shortly after it is served when Sirloin’s cached signature is near the end of its window. The media then fails to load the same way as an unsigned ref.

Rollout follows the recipe capability rollout requirement: deploy Sirloin with SignCharacterRecipeMedia, then Brisket with the resolveStorageMedia middleware, and publish recipes or families that contain storage-media refs only after every serving Brisket instance runs it. An older Brisket rejects the ref as an unsafe media URL.

Recipe capability extensions in Sirloin, Flank, or Brain must not make a customer recipe available before Brisket supports it. This includes new stage variants, field controls, actions, management interactions, and workflow outputs that the customer renderer consumes. New recipes composed entirely of supported features do not require a Brisket release.

  1. Add the required Brisket rendering and renderability-validation support, with representative recipe tests. Preserve support for existing supported published recipes and pinned sessions; extend capabilities rather than removing them.
  2. Deploy Brisket support before enabling the new capability for recipe authoring, publication, or customer routing in Sirloin, Flank, or Brain. Backend changes may deploy earlier only while the unsupported capability remains unavailable.
  3. Verify each affected family member’s customer-safe projection against Brisket’s validator and exercise the new interaction before publishing or enabling the recipes and family routes. Sirloin/Brain validation alone does not establish compatibility with the deployed Brisket renderer.

Deployment does not update already-open browser tabs. A rollout must retain compatibility with those clients or ensure they refresh before offering a feature their loaded build cannot render. A rollback must also preserve support for already-pinned recipes; hiding a family does not replace an existing session’s pin. These requirements apply to supported recipes and sessions and do not change the explicit exclusion of obsolete POC data in the Decision above.

The adult-enablement fixture routes the fictional-character choice to FORM/evidence. Its centered portrait and copy surround ordered recipe fields: text inputs, local image pickers, and single-option declaration checkboxes. Image fields configure maxFiles (1–10), uploadLabel, and addMoreLabel alongside existing MIME, size, and dimension validation. The fixture asks for the creation tool, up to five proof images, an optional social page, and a declaration.

This stage currently previews the form only. Selected files and entered values stay in component memory and are lost when leaving the page. The recipe provides submitLabel, but Submit is disabled even when fields are filled. No file upload, evidence persistence, review request, or adult-capability grant occurs. Publication and frontend validation permit only an unbound Back action on this variant.

Selected images have circular local previews beside their filenames. Removing an entry removes only that local proof selection and releases its preview URL; remaining entries keep their preview identity, including duplicate filenames.

Future upload integration can keep these field keys and page components. It must extend recipe validation and runtime handling together: a field’s upload action would prepare a signed URL, upload the file, and finalize it through the existing verified-upload lifecycle. A separate removal action would address a persisted proof attachment and remove its row only after the server confirms success. Proof attachments must stay separate from likeness and generation datasets. This is a future integration boundary, not functionality enabled by adding action IDs to the current recipe. No upload/remove actions are accepted on this variant yet, and no proof storage or migration is included.

Sources: apps/sirloin/internal/app/services/characterrecipeevidencevalidation.go, apps/brisket/src/features/characters-v2/recipe/evidence-form.ts, and apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/evidence-form/index.tsx.

A FORM stage with variant paginated displays one visible field per page. Single-choice controls use image cards (OPTION_GRID), text pills (CHIPS), or color-swatch cards (COLOR_PICKER). Picking an option highlights the whole choice for half a second, then saves its value in the local draft and advances; the last answer submits the stage’s primary action, displaying a spinner over the selected card until submission finishes. Choices and navigation stay locked during that pause. Question content fades and slides between pages, and the progress fill animates; reduced-motion preferences disable these animations. Back visits the preceding question before delegating to the stage’s existing Back or exit action. The progress bar counts visible questions.

For TEXT fields, label is the page title. Optional props.inputLabel supplies a separate visible label above the input; when omitted, the title remains the input’s accessible name without being repeated visually. props.placeholder sets the hint inside the empty input, and props.multiline: true renders a textarea. Both controls still submit a string. required: false keeps the field optional. An authored input label must contain non-whitespace text and fit within 240 UTF-8 bytes; omit it to leave the input visually unlabeled. Ordinary forms use label as the field label unless inputLabel overrides it. Existing props.hideLabel: true still hides either input label.

A single-choice field may set props.allowRandom: true to append a “Roll the dice” choice in the same shape as its options. It selects one declared option and follows the same save-and-advance path. Omitted or false hides the choice. Sirloin and Brisket reject this property on other field controls or outside FORM/paginated; its value must be boolean. This is presentation data in the existing field props, not a new option value or server action. Only this property may differ between matching creation and management field definitions; all other field contract data must remain identical. The VI reference recipes enable random choice on every image-card (OPTION_GRID) question; age and hair colour do not offer it. Skin details uses single-choice image cards whose option values are singleton arrays, preserving the existing skin_details payload shape.

Sources: apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/paginated-form/index.tsx, apps/brisket/src/features/characters-v2/recipe/validate-character-recipe.ts, and apps/sirloin/internal/app/services/characterrecipepropsvalidation.go.

Management sections may set discardOnExit: true. Brisket then discards the session and clears its local draft before returning to that character’s management page. A failed discard keeps the editor open for retry. Omitted or false preserves the session for resuming. This applies to the session-exit Back button; recipe Back actions inside the flow continue to navigate between stages. Explicit management navigation does not automatically resume the session it just left. The top-right X on interactive stages instead flushes the local draft and closes the view without discarding the session, even when Back is unavailable. Paywalls and loading screens retain their existing exit controls.

Failure-screen Exit preserves the session and returns to /characters without confirmation. Other recipe-authored discard behavior stays unchanged, including management discardOnExit, incomplete-screen completionAction: "discard-session", and RI KYC Buy an AI influencer instead, which discards the verification session before navigating to /shop. These in-session actions do not open a new discard confirmation.

Characters V2 creation session responses include credits_spent and free_creation_used, derived only from captured billing operations for the account and character. Sessions that edit an existing character receive zero and false, so discarding edits does not report historical creation charges. Reserved and released operations do not contribute. The discard confirmation explains that spent generation credits are not refunded and a consumed free creation is not restored. These warnings belong to explicit discard confirmations in the roster and character chooser. Cancelling performs no discard. The creation runner does not show these warnings or add a confirmation to recipe-authored exits.

Deploy Sirloin with these billing-summary fields before deploying Brisket’s discard warnings. Older Sirloin instances omit the fields, which decode as zero credits and an unused free creation; those defaults cannot establish that an existing session was uncharged. Complete the Sirloin rollout before relying on the warnings in Brisket.

On insufficient credits, the Characters V2 action handler fetches fresh account usage before opening the existing checkout dialog. Active subscribers receive the top-up/upgrade flow; non-subscribers receive subscription checkout. A failed status lookup preserves the form and reports an error instead of guessing. Checkout success refreshes usage and the session; the user explicitly retries the action, with Sirloin retaining final billing and entitlement authority. The checkout routing change is local to Characters V2; shared checkout hooks and other products retain their existing behavior.

Sources: apps/sirloin/internal/pkg/storage/charactersessionbilling.go, proto/sirloin/v5/character_session.proto, apps/brisket/src/features/characters-v2/views/roster/active-session-card/discard-session-dialog/index.tsx, and apps/brisket/src/features/characters-v2/views/creation/hooks/use-character-action.ts.

A default creation form or management form can opt into the shared horizontal settings presentation with props.formLayout: "settings". The field control keeps its semantic meaning and selects the reviewed visual:

  • CHIPS renders a pill rail.
  • COLOR_PICKER renders circular swatches. The default/reference swatch retains its person icon. props.swatchShape: "card" selects the larger swatch-card treatment for that color field.
  • OPTION_GRID always renders image cards. An ordinary option with missing or runtime-failed media uses the diagonal-stripe fallback; unsafe media is rejected during publication and renderability validation. The explicit default/reference option with the empty "" value (labelled Your in the reference recipes) uses a plain background when media is absent or fails, without a person icon or bottom fade; its label and selection indicator remain visible (apps/brisket/src/features/characters-v2/components/recipe-field/settings-choices/index.tsx:15-80, apps/brisket/src/features/characters-v2/components/recipe-field/settings-choices/settings-choice-visual/index.tsx:38-149, apps/brisket/src/features/characters-v2/recipe/media.ts:13-24).

Settings choice controls use the same horizontal rail. MULTI_SELECT uses checkbox semantics; the other choice controls remain single-select (apps/brisket/src/features/characters-v2/components/recipe-field/settings-choices/index.tsx:35-80). Desktop arrows and edge fades appear only while content remains in that direction; mobile keeps native swipe scrolling and hides those controls. props.groupLabel groups adjacent fields under a recipe-owned heading without changing submission semantics.

Choice controls that support props.defaultValue (OPTION_GRID, CHIPS, and COLOR_PICKER) must declare that value as an option. The renderer displays the default option first without mutating the recipe or confusing it with the current selection. RI recipes use the empty string for the reference-preserving option:

{
"key": "hair_length",
"control": "CHARACTER_CONTROL_KIND_CHIPS",
"label": "Hair length",
"required": false,
"options": [
{ "value": "", "label": "Your" },
{ "value": "short", "label": "Short" },
{ "value": "long", "label": "Long" }
],
"props": {
"defaultValue": "",
"groupLabel": "Hair"
}
}

The empty value remains a real submitted value when the action binds the optional field. Every authored option, including "", must have a generation-description mapping or option payload fragment so completion and later media generation preserve the selected intent. Creation and management definitions for the same key must remain protobuf-equal.

On a flat form.likeness stage, the continuation action may declare props.completedLabel. Brisket keeps the ordinary action label and outlined treatment while any visible likeness slot is empty. Once every slot has a saved asset, it uses completedLabel and a solid primary treatment. The four RI creation recipes therefore keep Add later while photos are missing and show Continue after all three reference photos are filled. The transition and server-side action remain the same; this is presentation derived from the session’s current assets.

Each RI reference-upload screen returns to the likeness overview when the customer chooses Add later (or Keep current photo for the face slot), just as a successful validation does. The overview remains the only place where its continuation action advances creation to Settings, so customers can review the updated dataset before deciding whether to add another photo or continue.

The adult-enablement recipes offer Upload my own dataset alongside the generated reveal. They open the existing form.likeness stage with three required upload items: face and chest, full body, and face and butt. Female recipes add optional vagina close-up, vagina frontal, and anus close-up references. Male recipes instead add optional penis close-up and anus close-up references. Each item navigates to its own photo upload and validation pair. Each required photo’s workflow checks image dimensions, face presence, and likeness against the server-resolved KYC selfie before materializing the accepted image. NSFW workflow explicitness permits nudity; age and celebrity moderation remain active. The full uploaded image is retained.

The optional intimate references use character.reference-update workflows with quality and moderation checks; they do not require a face or claim an identity match. Only an accepted, materialized generation reference counts as complete. Their empty matches output preserves the required photos’ existing verdicts through the same explicit result merge. Optional references never block Finish.

Likeness stages can declare props.groups. Existing flat recipes remain supported. Each group contains a label, optional capabilityKey, optional footerNote, and items. The footer is rendered only below that group; the RI recipes place “Private and never shown on your profile.” on the adult dataset group rather than showing it globally below standard references:

{
"label": "Adult dataset",
"capabilityKey": "adult",
"footerNote": "Private and never shown on your profile.",
"items": [{
"label": "Face & chest",
"asset": { "scope": "upload", "role": "face_chest_nsfw" },
"actionId": "choose-adult-dataset-face",
"required": true,
"impact": 50,
"icon": "face"
}]
}

In form.likeness, actionId identifies the item’s recipe navigation action. SFW groups have no capability requirement. Capability-gated groups use the server-resolved choice permissions, including during adult enrollment before the capability is earned. That rule does not bypass subscription, KYC, or server action checks. Management can launch a session through its existing FLOW section mechanism; grouped uploads belong to the session’s likeness stage. In mixed forms, only accessible groups contribute required photos and Finish bindings; an inaccessible group cannot be entered through a direct choice action.

Continuing from successful adult likeness results sets an action history barrier. Manual Back navigation cannot cross that checkpoint; explicit server-directed transitions can still return to likeness results for recovery. The four RI and digital-twin recipes offer only “This is me” on their character-source stage, with no hidden alternate source actions. Their likeness-results pages retain photo replacement, selfie Reset, and Continue. They omit the optional reviewActionId, its action, and the manual-review and incomplete-outcome stages. Repair upload pages use “Upload” headings. Removing the actions and stages from the recipes also prevents direct action requests from entering those branches.

Recipes that retain manual review can set reviewVisibility: "all-filled-with-mismatch". With this policy, About manual review appears only when every configured photo slot is filled and at least one current photo has a failed likeness verdict. Empty slots, unchecked photos, and verdicts for replaced photos do not qualify as failed matches. Omitting the policy keeps the existing review visibility behavior. A recipe can also set hideRecoveryActionsWhenMatched: once every configured photo matches its current asset, Brisket hides About manual review and selfie Reset. Otherwise Reset retains its existing permissions. Both settings are validated by Brisket and Sirloin; neither changes server authorization for an action.

The four RI and digital-twin recipes also set autoContinueWhenAllMatched: true. Each likeness-results photo can declare required: false; omitted required defaults to true. At least one photo must remain required. Manual Continue needs passing results for every required photo’s current asset, while automatic skipping still needs every configured photo to pass. The four RI recipes require only Face & Chest; the two body photos are optional. A missing or failed optional photo keeps the results page visible with all slots and the matched count, but does not block Continue once Face & Chest passes. Failed required photos keep Continue disabled until a successful re-upload. The same rules support other required subsets within the existing one-to-eight-photo limit.

For automatic skipping, Sirloin follows the stage’s continueActionId only when every configured photo has a passing result for its current asset. It applies that SAVE inside the execution or incoming SAVE transaction, so the client receives the next stage without displaying successful likeness results. Failed, missing, unchecked, or replaced photos keep the review page. The option defaults to false and permits only unbound, unconditional SAVE navigation to another always-visible interactive stage; it preserves the action’s history barrier and cannot complete the flow. Existing sessions retain their pinned recipe; publishing an updated recipe enables the option for new sessions.

The four recipes retain the existing initial likeness-check workflow. Their new Generate workflows prepare missing or failed body views from the verified Face & Chest upload; passing body photos supply their own references. Generate returns three SFW references and three adult outputs. Optional photo verdicts remain missing or failed on the results page. A successful face repair also copies the verified face into fallback reference slots for body photos that have not passed; Generate subsequently prepares those body views. This lets both generated and user-uploaded adult datasets complete while preserving the requirement that all identity references were produced in the enrollment and descend from verified uploads. The new repair and Generate workflows use separate pins; earlier workflow contracts and the post-enablement face-repair workflow remain available.

When configured, About manual review opens REVIEW/manual-likeness-review-request, a separate recipe-driven page retained for other and previously pinned recipes. Its props configure the verification-selfie row, dataset section copy, ordered photo references, and optional footer. It displays current ordinal-zero assets from those scoped slots in a horizontally scrollable row, with thumbnails filling their image containers using cover cropping. The ordinary fields array supports text inputs and multiline notes, including normal field validation and conditional visibility. The primary SAVE action must bind every configured field; an optional unbound Back action uses the existing session history. Sirloin exposes the session’s verification-selfie URL only on this page and likeness results, using the same account-scoped lookup.

The retained manual-review test fixture explicitly states that review is unavailable and nothing is submitted. Its Continue action saves the optional note into session values and routes to adult-enablement-incomplete. This action does not submit a staff review, start a workflow, or grant adult capability. Leaving that unfinished screen discards the session through its existing completion action. No review queue or new persistence schema is introduced.

Sources: apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/manual-likeness-review-request/index.tsx and apps/sirloin/internal/app/services/charactersessionselfie.go.

Finish creation requires all three dedicated NSFW upload slots and successful merged likeness verdicts naming those exact current upload IDs. Existing generated NSFW images cannot satisfy this requirement. Each upload merges its own matches entry, so retrying one photo preserves the other accepted results. The server checks the requirement when advertising action availability and again when applying the action. Both generated acceptance and uploaded completion use an explicit SAVE transition to the final actionless completion stage, which commits the session and grants the capability in the existing transaction.

The canonical recipe and workflow definitions live in apps/flank/app/fixtures/character-recipes/ and apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts. This is a Character V2 extension; legacy character paths and existing pinned recipe versions are unchanged. New definitions require publishing a new recipe version; they do not rewrite active session snapshots.

  1. Session action commits a queued character_executions row with the Brain pin denormalized at enqueue (brain_workflow_id + graph_digest), plus input_snapshot and the recipe-local workflow_id. Full recipe bytes stay on character_recipe_versions (FK via recipe_version_id + recipe_digest); they are not copied onto every execution row.
  2. Sirloin wake-up reads the pin columns, resolves recipe inputBindings against the execution input_snapshot session bag (values / result / assets), calls POST /api/workflow/execute-by-id with those bound inputs, then Acquires the execution with worker id bull:{brain_execution_id} so GetSession does not re-enqueue while Bull runs. The action that queued the execution resolves the same pin and inputs inside its own transaction and dispatches them after commit without reading the row back, so that path has no locked queued re-check before the POST. A workflow that binds the KYC selfie, an execution whose pin or bindings do not resolve in the transaction, a replayed action, and every GetSession re-dispatch still take the locked read. The POST and the Acquire run detached from the request’s cancellation, under their own 15-second bound: Brain runs an execution it accepted whether or not the caller is still waiting, so a request cancelled in between no longer rolls the lease back. This closes that one cause of a second Brain run. A replay or poll that arrives while the first dispatch is still in flight, an Acquire that fails after an accepted POST, and a process stopped in between can still dispatch twice (see Known deferrals).
  3. Brain runs the pinned graph on Bull (no Generation row).
  4. On terminal state Brain POSTs /webhooks/brain/character-completion with recipe-shaped outputs projected from the executed terminal output node and validated against Sirloin’s pinned outputSchema, plus assets[] collected from node terminalAssets. Generated object_keys use the Sirloin character_execution_id namespace (characters/generated/{character_execution_id}/…), not the Brain workflow execution id. Sirloin Acquire/Commit binds those assets against recipe assetOutputs (recipe loaded via the version FK). If Commit rejects a COMPLETED payload (schema/assets mismatch), Sirloin logs a Warn, commits a failed terminal and records failure_reason_code=runtime_failed (exposed on CharacterSession). It returns to the source stage immediately, except for the initial provisioning paywall deferral described above, and still ACKs the webhook with 200 so Brain does not retry-loop.

That failure path is for faults: a node crashed, a provider was unreachable, or the payload did not satisfy the pinned contract. It is not how a workflow says “no” to a customer. A workflow that reaches a business conclusion — a photograph it will not accept, for example — completes successfully and reports the verdict in its declared output, and Sirloin keys asset promotion, billing settlement, and stage advancement off that verdict. See Character workflow verdicts are outputs. For a photo-validation decline, Sirloin deletes the rejected candidate, releases the reservation, keeps the session on the validation stage with only its retry transition available, and records the authored reason in the operation result (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:556-599). Brisket keeps the local file preview until Sirloin exposes a durable replacement, so the rejected photo remains visible while the customer decides whether to try again (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/hooks/use-upload-preview.ts:23-91). By contrast, an execution failure records a safe failure reason, keeps the creation session active, and returns to the action’s source stage. This rule also applies when the action was intended to finish the recipe: a failed attempt does not make the session terminal. The source action remains available for another attempt. For photo validation, that means the customer can submit the retained photo again or choose a new one (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1314-1382).

VI retries normally restore the settings associated with the retained preview. If those producer inputs are inconsistent, failure settlement instead unlinks only the affected session’s generated preview, releases the failed attempt’s reservation, and returns to retry. Live images and adult generation’s SFW source images remain intact. The session result marks preview recovery as required until a generation succeeds; an empty reveal offers its recipe-authored secondary actions so navigation or subscription recovery cannot strand the user there. Database errors still roll back and retry normally.

Step 2 above resolves bindings against the execution’s session bag, with one exception. $.identity.kyc_selfie is the only binding source outside $.session.* that a recipe may name (apps/sirloin/internal/app/services/characterrecipevalidation.go:1623, apps/sirloin/internal/pkg/storage/characterexecutionbindings.go:20), and it is not in the bag at enqueue time. A recipe declares that it wants the account’s KYC selfie; it cannot say which one, and the value never reaches the browser.

When a pinned workflow carries that binding (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:74), the sole DB-backed hydration point (apps/sirloin/internal/pkg/storage/characterexecutiondispatch.go:20) resolves the account’s current KYC row (:102), pins the resulting identity anchor onto the session with COALESCE so it is written once and re-asserted thereafter (:160-171), and writes the selfie object key into the audited input_snapshot, bumping its digest and revision (:188). An account with no usable identity is refused before dispatch, and a binding that resolves to anything but a non-empty string fails input resolution (apps/sirloin/internal/pkg/storage/characterexecutionbindings.go:90-94).

This is the only place recipe inputs are enriched from database state rather than from the session bag. See Character Adult Identity Validation and Character identity validation is workflow-authored.

MEDIA_GENERATION and /webhooks/brain/media-completion are out of band for this plane.

The concurrency guarantees are layered deliberately, and new code should not add further layers on top of them:

  • A per-account pg_advisory_xact_lock serializes an account’s character operations. This is the outer mutual-exclusion boundary.
  • Fence tokens are load-bearing across lease takeovers: a takeover advances the fence, and any commit carrying an older fence is rejected inside the writing transaction. Bull dispatch uses the same Acquire/Commit control plane with a bull:… worker identity.
  • The event journal is load-bearing for idempotent replay: a repeated request fingerprint returns the recorded result rather than re-executing.
  • Row locks and OCC revisions beneath the advisory lock act as internal assertions, not as the primary serialization mechanism.

Because these already cover the failure modes, additional locking or a second idempotency layer would add risk without adding a guarantee.

The character QA gate requires an authenticated user whose verified primary email is on @foxy.ai and contains cv2 in its local part. Brisket consumes these trusted Clerk session claims:

Claim Required value
userPrimaryEmail The user’s primary email address as a string.
userPrimaryEmailVerified A boolean reflecting verification of that same primary address.

Populate these from Clerk’s authoritative identity data, never user-editable metadata or a constant verification flag. Complete claims avoid a currentUser() lookup. If the email claim is missing, or an eligible email lacks its verification claim, Brisket falls back to Clerk. Explicit verification claims are honored; other claimed emails without verification fail closed without a lookup. Lookup failure also fails closed. The resolver and eligibility rules live in apps/brisket/src/server/auth/primary-email.ts and apps/brisket/src/lib/foxy-staff.ts.

During rollout, verify a fresh session has both claims with the correct types and matching primary address, and that verified and unverified test identities receive the expected access. Keep the fallback for sessions missing claims. The live Clerk session configuration is external to this repository; this contract does not establish that any deployed instance already supplies it.

Eligible verified CV2 staff bypass the characters-v2 flag for both reads and mutations, including character starts and management actions. Setting the flag to false is therefore not a complete kill switch; incident and deployment procedures must also pause staff QA traffic.

The character-plane gRPC services (CharacterRecipeService, CharacterSessionService, and CharacterProjectionService) are reachable only on the private service mesh, and that reachability is the deliberate trust boundary. The typed identity fields in request bodies (account and actor IDs) are trusted because the caller is already inside the mesh; there is no per-call service credential on this plane.

Brain Beef workflow HTTP (/api/workflow/*) uses the shared Brain API key, matching the media execute path. Character-completion webhooks use X-Webhook-Secret, same pattern as media-completion.

Why character completion is HTTP, not CharacterRuntimeControlService

Section titled “Why character completion is HTTP, not CharacterRuntimeControlService”

CharacterRuntimeControlService (Acquire / Commit on Sirloin) was built for the sync Brain unary RunCharacterExecution path: mid-flight lease/checkpoint RPCs while Brain held the call open. That sync executor and Brain→Sirloin control client are gone (Run is UNIMPLEMENTED).

Bull EXECUTE reuses the media completion shape instead:

  1. Sirloin Acquires locally at wake-up (bull:{brain_execution_id} worker id) when dispatching execute-by-id.
  2. Brain runs on Bull; on terminal state it POSTs /webhooks/brain/character-completion (same secret/ops pattern as media-completion).
  3. Sirloin Commits locally in the webhook handler.

So the control gRPC service remains registered on Sirloin (proto + generated stubs stay green; useful if a future mid-flight path returns), but Brain does not dial it for the POC Bull path. HTTP webhook is intentional reuse of an existing Brain→Sirloin completion channel, not an accidental bypass of control RPCs.

This is a recorded decision, not an omission. The earlier HMAC actor-envelope (a signing secret plus replay nonce) was removed in commit 1efbc5b7e (“trust the private character mesh”) because it made previews brittle without protecting a real boundary, and the network-trust posture has since been reaffirmed by the owner. Fence tokens remain load-bearing for execution concurrency but are not authentication credentials.

Abandoned executions do not leak billing reservations. An expired-execution reaper (apps/sirloin/internal/app/worker/reapexpiredcharacterexecutions.go) fails executions whose lease has been stale past a grace window, using the same lease-takeover and terminal-commit path the runtime itself uses. The takeover advances the fence (so a resurrected worker cannot double-commit), and the terminal failure releases the reservation. The reaper never touches an execution with a live lease. If an expired execution also has malformed navigation history, recovery clears that untrusted history before returning the session to the source stage; ordinary interactive corruption still fails closed (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:1317-1382).

Character assets do not leak indefinitely either. A second reaper (apps/sirloin/internal/app/worker/reapleakedcharacterassets.go) collects assets nothing can reach any more and the working objects a finished run wrote under characters/scratch/. Four references protect a row, and each stands for a different caller: a live binding (every customer read path joins through it), an outgoing edge in characters.asset_derivations (the guards read root state through root_asset_ids, and the Strip dataset panel draws ancestors), provenance.errors (rejection evidence is unbound by construction, not by leaking), and a character_states snapshot naming it (a restore refuses a look whose assets are gone). Previews are excluded by retention policy rather than by reachability. Nothing younger than a day is collected, because an asset is legitimately unbound between an upload finalising and the action that binds it. The row is tombstoned before its bytes go, so a deleted object can never sit under a row still marked available; the scan is only a hint, and every protection is re-asked under a row lock before the tombstone (apps/sirloin/internal/pkg/storage/characterassetreaper.go).

Brain request errors are classified at the transport boundary. Deterministic request or pinned-contract rejections may settle the queued operation as a permanent runtime failure; unknown repository, queue, route, and infrastructure failures remain retryable. An execute-by-id 404 means the pinned workflow is unavailable, so Sirloin immediately settles the operation as failed and returns the session to its retryable source stage instead of leaving Brisket polling a queued operation. Permanent-dispatch recovery uses a bounded pair of stable acquire request ids, so reading a session cannot renew the same recovery forever (apps/sirloin/internal/app/services/charactersessionservices.go:554-725).

  • Character recipes and their pinned Brain workflows are provisioned manually for this POC. Preview and production environments must leave both FLANK_CHARACTER_RECIPE_SEED_ON_DEPLOY and BRAIN_CHARACTER_COMPUTE_FIXTURE_SEED_ENABLED unset. Publication preflight must provision the workflow through Brain with an explicit output schema, then verify the returned workflow UUID, canonical graph digest, schema, output bindings, and terminal completion projection before enabling the Brisket feature flag. Checked recipe UUIDs are placeholders and must be replaced with the manually published workflow IDs.
  • Public enablement requires a durable claim or idempotency boundary around the initial queued Sirloin-to-Brain dispatch. Concurrent pollers can otherwise observe the same queued operation before a lease is acquired and start more than one Brain execution.
  • Brain’s terminal completion webhook is currently a one-shot delivery after the workflow state is committed. Public enablement requires retry or reconciliation so a transient callback failure cannot leave the Sirloin session running.
  • Capacity must be measured before public enablement for session polling, buffered uploads, Brain worker concurrency, and expired-execution reaping. In particular, the reaper processes 100 rows every five minutes, so it is not sized for a large simultaneous outage without tuning or bounded batch draining.
  • Nest hybrid @GrpcMethod / connectMicroservice for CharacterRuntimeService (Validate + Health still use raw @grpc/grpc-js in Nest lifecycle).
  • Strip’s adoption of the generic projection is a known partial: the existing review UX is retained rather than rebuilt on the projection.
  • Mid-flight execution columns / mutations are dead on the Bull path. character_executions.checkpoint_snapshot and effect_snapshot, plus the CommitCharacterExecution oneof arms Checkpoint / EffectStart / EffectResult and the Sirloin CharacterRuntimeControlService gRPC surface, were built for the removed sync Brain executor. Bull + character-completion webhook only write terminal commits. Kept intentionally for proto compatibility (see Auth posture above); delete the columns, commit branches, and control service together if/when the POC drops mid-flight forever — do not strip them piecemeal.

The legacy ListCharacters API maps usable recipe-based characters from their current generation projection. Recipe type, fixed or selected gender, and available hair, body, tattoo, and BBL settings populate the existing response fields. Missing appearance settings remain absent. Recipe characters without a usable generation projection are excluded rather than returned as legacy drafts. The reference-trigger timestamp remains AvailableAt.

For recipe characters, isNsfw reflects effective adult enablement, including review restrictions. characterIsVerified additionally requires the owning account’s current approved Ondato verification with a nonempty verification ID; a synthetic Strip bypass does not qualify.

Recipe characters omit characterFeedback, requestedWelcomeImagesCount, welcomeEligible, selectedPackId, and selectedExampleId. Unsupported legacy filter, strictness, flirty, and glasses settings are also unset. Brisket skips welcome-media regeneration when the requested count is absent. Legacy characters retain explicit zero/false welcome values and their existing welcome behavior. The media-completion worker also skips the training-complete email when the character has a recipe version; legacy email behavior is unchanged.

Sources: apps/sirloin/internal/app/services/characters/listcharacters.go, apps/sirloin/internal/app/services/characters/listcharactersrecipe.go, apps/brisket/src/server/api/routers/character.ts, and apps/sirloin/internal/app/worker/checkmediageneration.go.

Deploy Sirloin fully before Brisket for the optional welcome-field change in proto/sirloin/v5/sirloin.proto. An older server omits the zero count on the wire, which the updated client treats as absent and would skip legacy regeneration.

Cross-character gallery responses also resolve recipe character names from the pinned recipe’s configured name field and committed character values, using the same Your character fallback as ListCharacters. The lookup batches only recipe characters on the returned media page. Legacy-only and character-scoped lists need no extra query, and stored legacy names are unchanged. Source: apps/sirloin/internal/pkg/storage/mediacharacternames.go.

Both direct recipe creation and family creation initialize a new Real character’s session with Character N. Sirloin counts the account’s non-deleted characters before inserting the new draft and uses that count plus one. The count includes legacy characters, Virtual characters, and drafts; it is scoped to the account. Numbers can repeat after deletions and are display names, not unique identifiers.

The name is stored under the pinned recipe’s policy.generation.characterTemplate.nameField, without reading suggestions. It is chosen inside the existing account-locked transaction and saved in the session’s initial values. Request replay and active-session recovery reuse those values. Completion commits the name to character state; users can rename it later. Virtual characters and existing-character sessions keep their previous behavior, and existing unnamed characters are not backfilled.

This adds one count query when starting a new RI session, with no additional network requests or recipe decoding. Sources: apps/sirloin/internal/pkg/storage/charactersession.go, apps/sirloin/internal/pkg/storage/charactersessionfamily.go, and apps/sirloin/internal/pkg/storage/charactersessioncommit.go.