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:
- Product authors must be able to compose supported creation and management flows from data.
- Sirloin must remain authoritative for state, authorization, billing, assets, and transitions.
- Recipes must not inject arbitrary frontend code or make Brain the owner of the character lifecycle.
- 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 key | Customer title | Demonstrated composition |
|---|---|---|
initial-vi-sfw | Custom AI influencer | Paginated questionnaire, subscription gate, settings confirmation, candidate generation, selection, regeneration, automatic finalization, success. |
initial-ri-sfw | RI Initial | Local-first face capture, subscription gate, upload and validation, provisioning, preview, optional likeness updates, settings, success. |
digital-twin-sfw | RI Subsequent | Face 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
| Layer | Owns | Must not own |
|---|---|---|
| Flank / recipe JSON | Authoring, section editing, validation feedback, immutable publish and rollback requests, workflow-pin bump assistance, and reference fixtures. | Customer session state, billing decisions, or workflow execution. |
| Sirloin | Canonical 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. |
| Brisket | A 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. |
| Brain | Published 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"| BCapability vocabulary
The contract intentionally uses a finite vocabulary:
| Capability | What the recipe can choose | Guardrail |
|---|---|---|
| Stage | Form, upload, review, asset picker, confirmation, or status; plus a reviewed variant and presentation copy. | Brisket and Sirloin both reject unsupported kinds or variants. |
| Field | Text, 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. |
| Action | Save, 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. |
| Transition | Stay, next visible stage, back through history, or explicit stage. | Targets must exist in the same flow and conditional chains require an unconditional fallback. |
| Condition | Exists, equals, all, any, or negation over allowed session paths. | Conditions are declarative and path-validated; they are not expressions or scripts. |
| Workflow pin | Recipe-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 policy | Recipe-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:
NEXTselects the next visible stage and requires an unconditional fallback.STAGEtargets a named stage in the same flow.BACKfollows server history and respects barriers. The action role controls presentation:BACKis reserved for the header control, whileSECONDARYwith aBACKtransition is a visible body action that returns through the same history.STAYperforms an intentional action without implying navigation.return_to_caller_if_presentlets one local form return to the stage that opened it, while retaining a declaredNEXTfallback 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
STAYactions (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:
- Author and publish a Brain workflow with purpose
CHARACTER_COMPUTE. - Record its UUID, graph digest, input binding contract, output schema, and asset outputs in the recipe workflow pin.
- Save the recipe as a draft and resolve every validation issue.
- Publish the Sirloin recipe version to move the route.
- 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 change | Recipe-only? | Required work |
|---|---|---|
| Rename a recipe, stage, field, option, button, or state copy | Yes | Edit catalog/stage/action data; publish a new recipe version. |
| Reorder unconditional stages | Usually | Recheck NEXT, conditional fallbacks, running stages, back barriers, and final stage validity. |
| Insert a supported form, upload, confirmation, review, picker, or status stage | Usually | Use a registered kind/variant and valid actions/bindings. |
| Make Continue or Skip start compute | Yes | Put the workflow and optional billing policy on that exact action; define running and success destinations. |
| Reuse a creation form as an editor | Yes | Use a LOCAL action with NEXT plus return_to_caller_if_present; do not duplicate the form. |
| Change Brain compute internals without changing its contract | Brain workflow only, then repin | Publish a new workflow UUID and explicitly bump the recipe pin. |
| Change workflow inputs, outputs, or asset roles | No single-file change | Update Brain graph contract and recipe pin together; validate every consumer. |
| Explain to a customer why compute declined their input | No single-file change | Return 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 outcome | No single-file change | Complete 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 supports | No | Add 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 props | No | Rejected by design; add a bounded typed capability instead. |
| Make a recipe bypass billing, authorization, ownership, or asset validation | No | Forbidden; 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 useminCount: 0so 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-v2feature 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.