Skip to content

Character creation is composed from versioned recipe capabilities

Context

Character creation needs to support materially different journeys without a new frontend and backend branch for every product variation. Examples include a visual character assembled from questionnaire choices, an initial real-identity character created from one photograph, and a subsequent real-identity character improved with optional body and angle references. Future journeys may reorder those steps, add a paywall, reuse a form as an editor, change a workflow, or insert another validation step.

Encoding each journey as application control flow would couple product iteration to coordinated Brisket, Sirloin, and Brain releases. It would also make labels such as “VI”, “RI Initial”, and “RI Subsequent” into domain discriminators even though they describe product recipes rather than different character storage models. The shared contract therefore deliberately carries no VI/RI field; provenance is the pinned recipe key, version, and digest (proto/tbone/v1/character_recipe.proto:9-20).

The design must satisfy four forces at once:

  1. Product authors must be able to compose supported creation and management flows from data.
  2. Sirloin must remain authoritative for state, authorization, billing, assets, and transitions.
  3. Recipes must not inject arbitrary frontend code or make Brain the owner of the character lifecycle.
  4. Unreleased Characters V2 work must remain invisible to customers who do not have the feature flag, without changing the existing CCV4 route.

The detailed runtime topology, concurrency model, and known production deferrals are documented in the Character Recipe Engine. This ADR records the higher-level capability decision and the rules for extending it.

Decision

Character creation and recipe-backed management are expressed as versioned recipe capabilities. A recipe selects from code-owned stages, controls, actions, transitions, conditions, billing policies, asset roles, and immutable Brain workflow pins. Recipes may arrange those building blocks, but they do not define executable code or weaken service-owned invariants.

The recipe is the product contract

The T-Bone CharacterRecipe envelope contains:

  • a stable route key and customer-facing catalog entry;
  • creation and management presentation flows;
  • workflow contract pins;
  • recipe-local billing policies;
  • protected policy values; and
  • a terminal output schema (proto/tbone/v1/character_recipe.proto:12-20).

Sirloin stores canonical immutable versions and routes a recipe key to one active version. Publication verifies the canonical bytes and validated digest, allocates a new version, and atomically moves the active route (apps/sirloin/internal/pkg/storage/characterrecipepublication.go:221-342). A running session remains tied to its pinned version; publishing a later version does not silently rewrite that session.

Catalog order and names are presentation data. The current fixtures demonstrate three routes rather than three runtime character types:

Fixture keyCustomer titleDemonstrated composition
initial-vi-sfwCustom AI influencerPaginated questionnaire, subscription gate, settings confirmation, candidate generation, selection, regeneration, automatic finalization, success.
initial-ri-sfwRI InitialLocal-first face capture, subscription gate, upload and validation, provisioning, preview, optional likeness updates, settings, success.
digital-twin-sfwRI SubsequentFace validation, optional full-body and alternate-angle branches, provisioning with uploaded or generated references, preview, likeness updates, settings, success.

The fixture catalog entries are source evidence for those names and entry stages (apps/flank/app/fixtures/character-recipes/initial-vi-sfw.json:1-23, apps/flank/app/fixtures/character-recipes/initial-ri-sfw.json:1-24, and apps/flank/app/fixtures/character-recipes/digital-twin-sfw.json:1-24).

Responsibility boundaries

LayerOwnsMust not own
Flank / recipe JSONAuthoring, section editing, validation feedback, immutable publish and rollback requests, workflow-pin bump assistance, and reference fixtures.Customer session state, billing decisions, or workflow execution.
SirloinCanonical recipes, publication, account-scoped sessions and characters, action availability, authoritative transitions, uploads and asset provenance, billing reservation/settlement, execution rows, and capability-projected reads.The full Brain node graph or customer rendering.
BrisketA generic runner over a fixed registry of reviewed stage/control variants, local draft UX, accessibility, subscription presentation, and Characters V2 views.Authoritative progression, arbitrary recipe-provided components, billing authority, or Brain calls.
BrainPublished CHARACTER_COMPUTE graphs, allowed-node enforcement, Bull execution, terminal output projection, and completion notification.Recipe routes, character/session ownership, entitlement, or billing.

Flank exposes a dedicated character-recipe editor that works with route versions, section buffers, publish, rollback, and workflow-pin bumps (apps/flank/app/routes/character-recipes.tsx:55-112). Brisket accepts only the registered kinds and variants in its trusted registry (apps/brisket/src/features/characters-v2/recipe/capabilities.ts:12-82) and rejects unknown or malformed recipe data instead of treating JSON as executable UI (apps/brisket/src/features/characters-v2/recipe/parse-character-recipe.ts:13-22, apps/brisket/src/features/characters-v2/recipe/validate-character-recipe.ts:35-67).

flowchart LR
A["Flank: author recipe"] -->|"draft / validate / publish"| S["Sirloin: recipe + session authority"]
B["Brisket: trusted recipe runner"] -->|"action id + revision + inputs"| S
S -->|"pinned workflow UUID + digest + bound inputs"| R["Brain: CHARACTER_COMPUTE"]
R -->|"terminal outputs + asset metadata"| S
S -->|"authoritative stage, operation and projection"| B

Capability vocabulary

The contract intentionally uses a finite vocabulary:

CapabilityWhat the recipe can chooseGuardrail
StageForm, upload, review, asset picker, confirmation, or status; plus a reviewed variant and presentation copy.Brisket and Sirloin both reject unsupported kinds or variants.
FieldText, option grid, chips, color, multi-select, or media upload with validation and visibility conditions.Requiredness, media limits, keys, conditions, and upload shape are validated before publication.
ActionSave, upload, execute, select asset, authorize, or local; label, role, bindings, running stage, entitlement, and successful transition.Sirloin resolves the action from the pinned recipe; the client cannot invent semantics.
TransitionStay, next visible stage, back through history, or explicit stage.Targets must exist in the same flow and conditional chains require an unconditional fallback.
ConditionExists, equals, all, any, or negation over allowed session paths.Conditions are declarative and path-validated; they are not expressions or scripts.
Workflow pinRecipe-local workflow id mapped to a published Brain UUID, graph digest, input bindings, output schema, and asset-output cardinality.Execution uses the immutable UUID and digest, never a workflow name or latest version.
Billing policyRecipe-local credits and included-use count, referenced by catalog display and/or an action.Declaring a catalog price does not charge; only executing an action that references the policy can reserve credits.

The wire vocabulary is defined in proto/tbone/v1/character_recipe.proto:74-109 and proto/tbone/v1/character_recipe.proto:131-255. Adding a new arrangement of existing capabilities is a recipe change. Adding a genuinely new stage kind, control, action semantic, condition operator, or trusted component is a contract and consumer change and must not be simulated through unvalidated props.

Server-authoritative navigation

Stage order is useful but not sufficient to determine progression. Sirloin owns the current stage and visited-stage history. It evaluates action conditions, back barriers, explicit transitions, and return-to-caller behavior (apps/sirloin/internal/pkg/storage/characterrecipecapabilities.go:128-218).

The following semantics are load-bearing:

  • NEXT selects the next visible stage and requires an unconditional fallback.
  • STAGE targets a named stage in the same flow.
  • BACK follows server history and respects barriers. The action role controls presentation: BACK is reserved for the header control, while SECONDARY with a BACK transition is a visible body action that returns through the same history.
  • STAY performs an intentional action without implying navigation.
  • return_to_caller_if_present lets one local form return to the stage that opened it, while retaining a declared NEXT fallback when used as the entry flow.
  • An automatic action must require an active subscription, be unique within its stage, and leave the stage. Publication rejects automatic STAY actions (apps/sirloin/internal/app/services/characterrecipecapabilityvalidation.go:280-323, apps/sirloin/internal/app/services/characterrecipecapabilityvalidation.go:395-452).

Brisket may optimistically show a recipe-authored running stage, but it still waits for the server session to confirm progression. It distinguishes stage-leaving actions from intentional STAY actions so a successful non-navigating action does not remain falsely pending (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/action-buttons/index.tsx:14-20, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/action-buttons/index.tsx:94-128).

Local data is presentation state, not authority

presentation.*.local_data supports temporary browser-owned values such as a photo held before subscription. It cannot participate in server conditions, billing, workflow inputs, or persisted session state (proto/tbone/v1/character_recipe.proto:74-85). A LOCAL action is still sent to Sirloin for the authoritative transition; local values do not turn the browser into the navigation authority (proto/tbone/v1/character_recipe.proto:166-177).

This separation permits a local-first RI upload experience without uploading the photo before entitlement is known. It is not a general offline workflow engine.

Compute is bound by contract, not by stage name

An execute/upload/authorize action references a recipe-local workflow id. Sirloin resolves its value and asset bindings into a validated input snapshot, verifies the pinned Brain workflow UUID and graph digest, and only then reserves billing and creates the queued execution (apps/sirloin/internal/pkg/storage/characterexecution.go:287-348). The full node graph remains in Brain; the recipe stores only the immutable pin and the contract projection (proto/tbone/v1/character_recipe.proto:258-288).

Brain runs CHARACTER_COMPUTE through its existing workflow execution machinery and returns terminal outputs and asset metadata through the character-completion webhook. Sirloin binds those assets against the recipe contract; Brain does not write character or billing state (apps/brain/src/modules/domain/workflow/services/sirloin-webhook.service.ts:51-92).

Therefore workflow invocation is attached to an action, not inferred from a particular stage position. An author can make either Continue or Skip start provisioning, provided each action has the required workflow, input bindings, billing policy when applicable, running stage, and successful transition. Moving a stage does not implicitly move a charge or compute call.

Entitlement and billing are separate

An active-subscription entitlement decides whether an action is available or whether Brisket should present checkout. It is not itself a credit charge. A billing_policy_id on the action selects the charge policy for that specific operation (proto/tbone/v1/character_recipe.proto:234-255).

For a billed action, Sirloin validates inputs first, then reserves the first-character allocation or credits before it inserts the execution. Insufficient credits reject the action before compute starts (apps/sirloin/internal/pkg/storage/characterbilling.go:35-109, apps/sirloin/internal/pkg/storage/characterexecution.go:305-348). Successful terminal completion captures the reservation; failure releases it (apps/sirloin/internal/pkg/storage/characterbilling.go:215-294). This prevents a recipe from finalizing expensive work and charging afterward.

The catalog’s billing policy reference exists only to show the matching price before the user starts. It must not be used to infer where charging happens.

Rendering and asynchronous states are recipe-aware but code-owned

Brisket’s runner derives the current renderer from the stage kind and reviewed variant, keeps the server stage authoritative, and shows a stable unsupported state if the pinned stage is absent (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stage-renderer/index.tsx:69-95, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/index.tsx:92-108). When a session is running, action controls are locked so repeated clicks cannot submit the same transition. The processing-status renderer deliberately keeps only plain text draft fields editable while non-text inputs and actions remain locked (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/index.tsx:49-50, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/status/processing/index.tsx:119-154).

Each stage view owns the arrangement of its fields, media, and controls. Shared presentation primitives remain reusable, but progression stays in the generic runner and Sirloin. A visual redesign therefore changes the selected stage view without moving transitions, billing, or action availability into that component. Sirloin publication and Brisket parsing enforce matching renderer/field/action boundaries, including the bespoke upload, likeness, asset-picker, completion, and management shapes (apps/sirloin/internal/app/services/characterrecipevalidation.go:603-887, apps/brisket/src/features/characters-v2/recipe/validate-character-recipe.ts:244-748).

The stage title and description define its ready-state copy. Recipe props may provide loading, processing, failure, and finalization-failure overrides; renderers supply conservative fallbacks through stageStateCopy in apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stage-copy.ts. Photo validation is one shared stage variant for face, full-body, and alternate-angle roles. Its passed title and description come from the stage, while processingTitle, processingDescription, and successLabel are validated recipe overrides. Older pinned versions fall back to their own stage copy, not renderer-owned product wording. The supplied photo remains visible during validation, and success means that it passed requirements—not that a character or dataset is complete (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/index.tsx:123-163, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stage-renderer/index.tsx:150-165, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/photo-validation/index.tsx:75-229, and apps/sirloin/internal/app/services/characterrecipepropsvalidation.go:77-82).

Candidate regeneration is another state of the candidate-selection stage rather than a duplicate character-details step. Once regeneration is submitted, stale candidates are hidden while the same stage shows processing; the replacement candidates appear in place when the operation completes. Selecting a final candidate uses a distinct finalization action and does not re-enter regeneration. The candidate renderer distinguishes generation from finalization using the selected group recorded in the session result (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/asset-picker/index.tsx:48-127).

Authoring, fixtures, and promotion

The fixture JSON files are executable examples and contract-test inputs. They are not a production migration system. The bootstrap seed planner fills only an empty or draft-only route and deliberately skips a route with a published active version (apps/flank/app/lib/character-recipe-seed.ts:10-36). The script likewise refuses to overwrite a published recipe and requires intentional changes to be made through Flank or after explicitly clearing disposable local data (apps/flank/scripts/seed-character-recipes.ts:1-8, apps/flank/scripts/seed-character-recipes.ts:43-68).

POC Brain workflow fixtures are also opt-in. The startup service returns without touching workflow storage unless BRAIN_CHARACTER_COMPUTE_FIXTURE_SEED_ENABLED=true; when enabled, it creates and publishes missing fixtures but skips existing published rows (apps/brain/src/modules/domain/workflow/poc-fixtures/character-compute-fixture-seed.service.ts:12-70). Preview and production must leave both recipe and Brain fixture seeding disabled.

The promotion order is:

  1. Author and publish a Brain workflow with purpose CHARACTER_COMPUTE.
  2. Record its UUID, graph digest, input binding contract, output schema, and asset outputs in the recipe workflow pin.
  3. Save the recipe as a draft and resolve every validation issue.
  4. Publish the Sirloin recipe version to move the route.
  5. Test through the Characters V2 feature flag before any broader rollout.

Publishing Brain alone never changes a live recipe. Publishing the recipe is the explicit ship gate. This store split is governed by Character compute graphs pin by Brain workflow UUID.

Extension rules for authors and agents

Use this decision table before changing code:

Desired changeRecipe-only?Required work
Rename a recipe, stage, field, option, button, or state copyYesEdit catalog/stage/action data; publish a new recipe version.
Reorder unconditional stagesUsuallyRecheck NEXT, conditional fallbacks, running stages, back barriers, and final stage validity.
Insert a supported form, upload, confirmation, review, picker, or status stageUsuallyUse a registered kind/variant and valid actions/bindings.
Make Continue or Skip start computeYesPut the workflow and optional billing policy on that exact action; define running and success destinations.
Reuse a creation form as an editorYesUse a LOCAL action with NEXT plus return_to_caller_if_present; do not duplicate the form.
Change Brain compute internals without changing its contractBrain workflow only, then repinPublish a new workflow UUID and explicitly bump the recipe pin.
Change workflow inputs, outputs, or asset rolesNo single-file changeUpdate Brain graph contract and recipe pin together; validate every consumer.
Explain to a customer why compute declined their inputNo single-file changeReturn the verdict and its message in the workflow’s declared output and render that; do not surface the execution error field, which carries unfiltered engine text.
Let a compute step reject input as an expected outcomeNo single-file changeComplete the workflow successfully with a declining verdict; reserve execution failure for faults. Give the accept-only asset roles minCount: 0.
Add a new UI interaction that no registered variant supportsNoAdd a reviewed contract capability, Sirloin validation/runtime semantics, Brisket renderer, Flank authoring support, and tests.
Add arbitrary JavaScript, component names, URLs, or condition expressions through propsNoRejected by design; add a bounded typed capability instead.
Make a recipe bypass billing, authorization, ownership, or asset validationNoForbidden; those are Sirloin invariants.

Before publishing a changed recipe, verify these invariants:

  • Every stage/action/field key is stable and unique in its scope.
  • Every transition has an explicit kind and a reachable valid target.
  • Conditional stage chains end in an unconditional fallback.
  • Automatic actions are unique, entitled, and stage-leaving.
  • Workflow and billing behavior is attached to the intended action rather than inferred from layout.
  • Required value and asset bindings are satisfiable at the action’s source stage.
  • Running stages are unconditional and accurately describe the in-flight work.
  • Output schemas and asset roles/cardinality match the pinned Brain workflow.
  • A workflow that can decline declares its verdict field as required in outputSchema, and the asset roles it produces only when accepting use minCount: 0 so a decline is not rejected at commit.
  • Presentation media uses approved first-party paths and alt text.
  • Existing session compatibility is considered before deleting or renaming keys.

Production isolation

Characters V2 has two Brisket gates. The characters route renders CCV4 unless the characters-v2 PostHog flag is true (apps/brisket/src/app/(main-layout)/characters/page.tsx:1-28), and the Characters V2 tRPC router independently rejects direct access for accounts without that flag (apps/brisket/src/server/api/routers/characters-v2.ts:25-40). Consequently, the recipe UI and API are not exposed merely because the code and recipe rows exist.

This isolation does not by itself declare the POC production-ready. Public rollout still depends on the reliability and capacity deferrals in the Character Recipe Engine, including durable Brain completion delivery and dispatch idempotency. Feature-flag removal is a separate release decision.

Consequences

Benefits

  • Product teams can create materially different onboarding journeys by composing a bounded vocabulary instead of adding recipe-specific service branches.
  • VI/RI labels remain presentation concepts; character storage and lifecycle stay uniform.
  • Stage order, compute, entitlement, and billing are explicit and independently reviewable.
  • Workflow changes are versioned and pinned, so a Brain publish cannot silently alter a customer flow.
  • The same forms and reference-update flows can be reused without copying UI code.
  • Invalid or unsupported recipe capabilities fail before publication or at a stable customer boundary rather than executing partially.
  • The feature can ship dark behind Characters V2 gates while CCV4 remains the default.

Costs and risks

  • Recipe publication becomes a real release boundary and needs the same review care as code.
  • Flexibility is bounded by the trusted capability registry. A genuinely new interaction still requires coordinated contract, validation, and renderer work.
  • Stage/action JSON can be structurally valid yet product-confusing. Automated validation cannot replace flow walkthroughs.
  • Changing stable keys can strand pinned sessions. Forward-only POC cleanup is acceptable while unreleased; post-release compatibility requires an explicit migration policy.
  • Brain and recipe fixtures are useful for local POC bootstrap but are not an operator-safe production provisioning mechanism.

Rollback

  • Recipe-only regression: route the recipe key back to a previously published version that still passes the current publication and workflow-pin checks.
  • Compute regression: republish a recipe version pinned to the previous Brain workflow UUID and digest.
  • Customer-surface regression: disable the characters-v2 feature flag; CCV4 remains the route fallback.
  • Do not roll back by editing immutable published bytes, following latest workflow by name, or mutating sessions in place.

Alternatives Considered

  • Hardcode one React/backend flow per recipe. Rejected because every product rearrangement would require coordinated releases and duplicate session, billing, and error handling.
  • Add VI/RI/subtype columns and switch statements throughout the stack. Rejected because these labels describe recipe provenance, not different persistence or lifecycle semantics.
  • Let recipes name arbitrary React components or execute scripts. Rejected because recipe publication would become code deployment and bypass review, security, and compatibility boundaries.
  • Make Brain own the entire onboarding and character lifecycle. Rejected because Brain is the compute engine; it should not own customer session, recipe routing, entitlement, billing, or durable character authority.
  • Infer compute and billing from stage names or positions. Rejected because reordering a recipe would silently move side effects. Actions explicitly own workflow and billing references.
  • Always use the latest Brain workflow by name. Rejected because a workflow publish would change active funnels without a recipe review or rollback pin.
  • Automatically overwrite published recipes/workflows from fixtures on startup. Rejected because deployment would become an implicit production migration and could replace operator-authored definitions.