Skip to content

Character Recipe Engine

Character Recipe Engine

Decision

Characters are created and managed by recipe, not by feature-specific code paths. 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 (reordering a stage, changing an option catalog, repinning a Brain workflow UUID) is the normal way to change a character flow; a Sirloin, Brisket, or Brain release is not. “Visual” (VI) and “Real” (RI) are recipe concepts, not code branches, storage discriminators, or lifecycle variants: no character, session, execution, billing, or asset record carries a VI/RI field. Three reference recipe documents live in apps/flank/app/fixtures/character-recipes/: digital-twin-sfw, initial-ri-sfw, and initial-vi-sfw. They are authoring and contract-test inputs, not a deployment mechanism. Character recipes and their pinned CHARACTER_COMPUTE workflows are created and published manually, with the Brain workflow published before the Sirloin recipe. 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 platform is deliberately open-ended, so a new recipe that mixes questionnaire and upload stages should require no new enum, table, RPC, or renderer. 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. Catalog cards resolve their displayed price through catalog.billing_policy_id, so pricing does not depend on stage or action order.

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

ConcernLives inNotes
Presentation (stages, fields, actions), billing policies, capability projectionSirloin canonical_recipeSingle customer-facing SoT; Brisket never reads Brain for render.
Execute binding pinSirloin CharacterWorkflowbrain_workflow_id (published Brain row UUID) + graph_digest + contract projection (purpose, inputBindings, outputSchema, assetOutputs). No embedded graph.
Full node/edge graph bodyBrain workflow tablePurpose CHARACTER_COMPUTE; node availability via registry allowedPurposes (omit = all purposes); SoT for Bull execution.
Ship gateTwo-stepPublishing 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.

Reading a recipe object

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

PropertyMeaning
schemaVersion and keyIdentify the contract version and stable recipe route.
catalogSupplies the card title, description, media, badges, and displayed billing policy. It does not start creation or charge by itself.
presentation.creationContains the creation entry stage, ordered stage definitions, and browser-local data declarations.
presentation.managementContains the recipe-backed editing sections available after creation. Each section owns its own flow.
workflowsMaps recipe-local workflow ids to immutable Brain pins, input/output schemas, and asset roles. Brisket receives only the customer-safe projection.
billingPoliciesDefines the recipe-local prices that actions may reference.
policyCarries 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).

Responsibility boundaries

LayerOwnsDoes not own
T-Bone (authored in Flank)Recipe meaning: stage graph and order, trusted component keys and props, field/option/dataset definitions, visibility conditions, action bindings to recipe-local workflow ids, management projections, and dynamic thresholds that Sirloin validates against code constraints. Character compute graphs authored as Brain workflows with purpose CHARACTER_COMPUTE.Physical storage, authorization, billing, or the protected safety floor.
SirloinPhysical 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.
BrisketPresentation only: a fixed registry mapping recipe component and control keys to reviewed React components, a generic stage runner and condition evaluator, accessibility, theming, and a session client with stable request IDs. Unknown keys or malformed props fail closed into a stable unsupported-recipe state.Any authoritative state; recipes select trusted components but cannot inject code.
BrainCompute: 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

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

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).

Execution path (character EXECUTE)

  1. Session action commits a queued character_executions row with the Brain pin denormalized at enqueue (brain_workflow_id + graph_digest), plus input_snapshot and the recipe-local workflow_id. Full recipe bytes stay on character_recipe_versions (FK via recipe_version_id + recipe_digest); they are not copied onto every execution row.
  2. Sirloin wake-up reads the pin columns, resolves recipe inputBindings against the execution input_snapshot session bag (values / result / assets), calls POST /api/workflow/execute-by-id with those bound inputs, then Acquires the execution with worker id bull:{brain_execution_id} so GetSession does not re-enqueue while Bull runs.
  3. Brain runs the pinned graph on Bull (no Generation row).
  4. On terminal state Brain POSTs /webhooks/brain/character-completion with recipe-shaped outputs projected from the executed terminal output node and validated against Sirloin’s pinned outputSchema, plus assets[] collected from node terminalAssets. Generated object_keys use the Sirloin character_execution_id namespace (characters/generated/{character_execution_id}/…), not the Brain workflow execution id. Sirloin Acquire/Commit binds those assets against recipe assetOutputs (recipe loaded via the version FK). If Commit rejects a COMPLETED payload (schema/assets mismatch), Sirloin logs a Warn, commits a failed terminal, returns the session to the source stage with failure_reason_code=runtime_failed (exposed on CharacterSession), 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).

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

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

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

Auth posture

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

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

Bull EXECUTE reuses the media completion shape instead:

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

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

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

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 generation guard walks 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

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

See also