Character Recipe Engine
Character Recipe Engine
Section titled “Character Recipe Engine”Decision
Section titled “Decision”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.
Photo-first creation (optional)
Section titled “Photo-first creation (optional)”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 managementFLOWwithout 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 persistedcompleted_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: trueto omit its menu card while keeping its flow startable and resumable. OnlyFLOWsections can be hidden. Optional improvement sections should leavediscardOnExitfalse 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.
Recipe document vs compute graph
Section titled “Recipe document vs compute graph”| 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.
Recipe families
Section titled “Recipe families”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:
ONBOARDINGis preferred when the account has never had a character with statusCHARACTER_STATUS_AVAILABLEandavailable_atset.DEFAULTis required. It is selected after the account has ever had a character with statusCHARACTER_STATUS_AVAILABLEandavailable_atset, 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.
Family-only catalog rollout gate
Section titled “Family-only catalog rollout gate”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.
- Deploy Sirloin with migration 163 and the family gRPC methods, including
ListResolvedCharacterRecipeFamilies, before deploying Brisket or Flank clients that call them. Deploy regenerated consumers withCharacterRecipeGenderConstraintsupport 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 withCHARACTER_MANAGEMENT_FEATURE_KIND_COMING_SOONsupport fromproto/tbone/v1/character_recipe.proto. Older consumers can reject this enum; do not publish the VI fixtures while those consumers are still serving. - From
apps/flank, publish the checked-in recipes and dependent RI family withSIRLOIN_GRPC_URL=<sirloin-grpc-url> pnpm seed:character-recipes. The combined seed usesCHARACTER_RECIPE_SEED_REPLACE(defaulttrue) for recipes and passes that decision to families unlessCHARACTER_RECIPE_FAMILY_SEED_REPLACEis set explicitly. Directpnpm seed:character-recipe-familiesdefaults its replacement flag tofalse; set it totrueonly for a reviewed family-only replacement. Seed audit actors default toflank:recipe-seedandflank:recipe-family-seedand can be overridden with the correspondingFLANK_CHARACTER_RECIPE_SEED_ACTOR_IDandFLANK_CHARACTER_RECIPE_FAMILY_SEED_ACTOR_IDvariables. - Verify the
initial-ri-sfw,digital-twin-sfw,initial-ri-male-sfw, anddigital-twin-male-sfwrecipe routes were published. - Verify routing and pricing independently. Fetch the resolved family catalog as
both a new test account and an account with a historical
CHARACTER_STATUS_AVAILABLEcharacter 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. - 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
DEFAULTautomatically; 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 inapps/flank/app/fixtures/character-recipes/digital-twin-sfw.json,apps/flank/app/fixtures/character-recipes/digital-twin-male-sfw.json, andapps/brain/src/modules/domain/workflow/poc-fixtures/character-compute.fixtures.ts. - 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.
Reading a recipe object
Section titled “Reading a recipe object”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.
Responsibility boundaries
Section titled “Responsibility boundaries”| 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.
- 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 (parseCharacterRecipeinapps/brisket/src/features/characters-v2/recipe/parse-character-recipe.ts). isRenderableCharacterRecipechecks 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).- 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
(
ListResolvedCharacterRecipeFamiliesandcharacterRecipeFamilyCatalogSkipsinapps/sirloin/internal/pkg/storage/characterrecipefamilyresolution.go,useRosterinapps/brisket/src/features/characters-v2/views/roster/hooks/use-roster.ts,apps/brisket/src/features/characters-v2/views/creation/index.tsx:29-59). - The server’s
currentStageIdselects the stage.StageRenderermaps 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). - 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).
Storage-media refs
Section titled “Storage-media refs”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_foundat the ref’s JSON path when its object does not exist. Draft saves are not checked (missingCharacterRecipeStorageMediainapps/sirloin/internal/app/services/characterrecipemediaservices.go). - Signing:
SignCharacterRecipeMediasigns 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
charactersV2ProcedureandcharactersV2QaProceduremiddleware replaces every whole-string ref in a response with a signed URL before it reaches the browser. Brisket’s validator therefore only ever seeshttps://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 (resolveStorageMediainapps/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 rollout requirement
Section titled “Recipe capability rollout requirement”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.
- 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.
- 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.
- 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.
Local creation-proof form
Section titled “Local creation-proof form”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.
Paginated creation questionnaires
Section titled “Paginated creation questionnaires”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.
Recipe-driven settings forms
Section titled “Recipe-driven settings forms”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:
CHIPSrenders a pill rail.COLOR_PICKERrenders circular swatches. The default/reference swatch retains its person icon.props.swatchShape: "card"selects the larger swatch-card treatment for that color field.OPTION_GRIDalways 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 (labelledYourin 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.
Grouped likeness datasets
Section titled “Grouped likeness datasets”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.
Execution path (character EXECUTE)
Section titled “Execution path (character EXECUTE)”- Session action commits a queued
character_executionsrow with the Brain pin denormalized at enqueue (brain_workflow_id+graph_digest), plusinput_snapshotand the recipe-localworkflow_id. Full recipe bytes stay oncharacter_recipe_versions(FK viarecipe_version_id+recipe_digest); they are not copied onto every execution row. - Sirloin wake-up reads the pin columns, resolves recipe
inputBindingsagainst the executioninput_snapshotsession bag (values/result/assets), callsPOST /api/workflow/execute-by-idwith those bound inputs, then Acquires the execution with worker idbull:{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 lockedqueuedre-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). - Brain runs the pinned graph on Bull (no Generation row).
- On terminal state Brain POSTs
/webhooks/brain/character-completionwith recipe-shapedoutputsprojected from the executed terminal output node and validated against Sirloin’s pinnedoutputSchema, plusassets[]collected from nodeterminalAssets. Generatedobject_keys use the Sirloincharacter_execution_idnamespace (characters/generated/{character_execution_id}/…), not the Brain workflow execution id. Sirloin Acquire/Commit binds those assets against recipeassetOutputs(recipe loaded via the version FK). If Commit rejects a COMPLETED payload (schema/assets mismatch), Sirloin logs a Warn, commits a failed terminal and recordsfailure_reason_code=runtime_failed(exposed onCharacterSession). 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.
Dispatch-time identity resolution
Section titled “Dispatch-time identity resolution”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.
Concurrency: which layer is load-bearing
Section titled “Concurrency: which layer is load-bearing”The concurrency guarantees are layered deliberately, and new code should not add further layers on top of them:
- A per-account
pg_advisory_xact_lockserializes 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.
Auth posture
Section titled “Auth posture”Brisket session claims
Section titled “Brisket session claims”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.
Internal services
Section titled “Internal services”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:
- Sirloin Acquires locally at wake-up (
bull:{brain_execution_id}worker id) when dispatchingexecute-by-id. - Brain runs on Bull; on terminal state it POSTs
/webhooks/brain/character-completion(same secret/ops pattern as media-completion). - 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.
Reliability
Section titled “Reliability”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).
Known deferrals
Section titled “Known deferrals”- 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_DEPLOYandBRAIN_CHARACTER_COMPUTE_FIXTURE_SEED_ENABLEDunset. 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/connectMicroservicefor CharacterRuntimeService (Validate + Health still use raw@grpc/grpc-jsin 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_snapshotandeffect_snapshot, plus theCommitCharacterExecutiononeof armsCheckpoint/EffectStart/EffectResultand the SirloinCharacterRuntimeControlServicegRPC 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.
Legacy character-list compatibility
Section titled “Legacy character-list compatibility”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.
Initial RI names
Section titled “Initial RI names”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.
See also
Section titled “See also”- Character compute graphs pin by Brain workflow UUID for the store-split ADR (Proposed).
- gRPC Mesh Integration Playbook for the transport and service-to-service topology.
- Create Dialog Routing for the related data-driven-over-hardcoded pattern on the generation surface.