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
| 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.
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. |
catalog | Supplies the card title, description, media, badges, and displayed billing policy. It does not start creation or charge by itself. |
presentation.creation | Contains the creation entry stage, ordered stage definitions, and browser-local data declarations. |
presentation.management | Contains the recipe-backed editing sections available after creation. Each section owns its own 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).
Responsibility boundaries
| Layer | Owns | Does 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. |
| 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 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. |
| 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
- 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). 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).- 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). - 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-313). - 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)
- 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. - 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, returns the session to the source stage withfailure_reason_code=runtime_failed(exposed onCharacterSession), 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_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
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:
- 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
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_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.
- Production enablement (POC). Sirloin still defaults / rejects
adapter=brainin 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/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.
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.