Skip to content

Character workflow verdicts are outputs, not execution failures

Context

A character recipe runs Brain workflows that answer product questions. The most common one is a validation step: the customer uploads a photograph and the workflow decides whether it can be used. “No” is an ordinary, expected answer. It is not a malfunction.

Before this decision, that answer was delivered by throwing. A recipe author failed the graph from the throw node, optionally attaching a machine-readable reason (apps/brain/src/modules/domain/workflow/nodes/core/throw.node.ts:37-53). Brain marked the execution FAILED and forwarded the reason with the completion webhook (apps/brain/src/modules/domain/workflow/processors/execution-runner.processor.ts:188-203). Sirloin then hung four separate behaviours off that terminal state: it marked the uploaded asset deleted instead of binding it to its role, released the billing reservation, settled the character lifecycle as unsuccessful, and returned the session to the stage the action started from.

That arrangement is internally consistent, which is why it survived. It is also wrong in three ways that compound.

Operationally, a declined photograph is indistinguishable from an outage. Both land as FAILED executions. The observability surfaces read the same error string for a blurry selfie and for a provider refusing connections, so the workflow dashboards cannot answer “is the system healthy” without decoding reason codes.

The reason did not survive the trip. The original Sirloin path collapsed authored codes to a small internal allowlist before anything reached the wire. An author therefore could not distinguish two decline reasons, let alone explain them.

The explanation has nowhere safe to travel. The thrown message is merged into the same error field that carries every incidental provider and node failure, so no customer-facing surface may render it. Brisket prefers the authored workflow explanation and falls back to trusted copy for recognized codes (apps/brisket/src/features/characters-v2/views/creation/recipe-runner/operation-failure.ts:48-61, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stage-copy.ts:3-13).

Widening the failure path to carry authored copy was considered and rejected below. The deeper problem is that business logic is being expressed through the exception channel, so every improvement has to smuggle product meaning through a transport designed for faults.

The contract already anticipates the alternative. Workflow pins declare an output_schema (proto/tbone/v1/character_recipe.proto:266-282), Sirloin validates terminal output against it with a full JSON Schema implementation (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:474-476), and several pins already declare a decorative status string in that schema. The vocabulary for a structured outcome exists; nothing reads the decorative field, and it is not the verdict this ADR defines.

Decision

A workflow that reaches a business conclusion completes successfully and reports the conclusion in its declared output. Execution failure is reserved for faults: a node that crashed, a provider that was unreachable, a graph that could not run. throw remains available for genuine faults and for authoritative hard stops; it stops being the way a recipe says “no”.

The verdict is a nullable failure field

Every CHARACTER_COMPUTE and DATASET_MANIPULATION workflow returns:

failure: WorkflowFailure | null

where WorkflowFailure is declared once in Brain code as { code?: string; message: string } and attached to each workflow’s outputSchema. null means the run accepted; a non-null object carries the authored explanation for the customer.

The decorative status string that today’s fixtures hardcode (validated, generated, updated, ready, completed) is removed. Nothing in Sirloin or Brisket reads it. It looks like a verdict and is not one, which is worse than its absence.

The field is named failure, not error, because the completion path already injects an error key into the same output map when the execution itself fails (apps/sirloin/internal/app/worker/braincharacterwebhook.go:273-275). Reusing that key would make an authored decline indistinguishable from unfiltered engine text in the same map. failure is free in that map.

result.failure is not session.failureReasonCode

These names are adjacent and must not be confused:

SurfaceTrustRole
operation.result.failureAuthored product copy, schema-validatedCustomer-visible explanation of a successful decline
session.failureReasonCodeAllowlisted machine code on the session rowNarrow wire signal for legacy and fault paths (validation_declined, runtime_failed)

Brisket prefers result.failure.message when present. Renderer-owned copy keyed off failureReasonCode remains a fallback only.

The output schema is the contract

Because the recipe pins output_schema and Sirloin validates terminal output against it before committing, a workflow cannot silently stop returning failure — the commit is refused instead. The coupling between the graph, the recipe, and the renderer is therefore enforced at the boundary that matters, without inventing a binding layer.

This forces one concrete change to any pin that can decline. Today every asset role in the POC recipes declares minCount ≥ 1 and every assets array declares minItems ≥ 1, so a declining run that produces no asset would be rejected at commit. A workflow that can decline therefore declares min_count: 0 for the roles it produces only when it accepts, and expresses the accepting-path cardinality as an output_schema if/then conditional on failure being null rather than as a floor that applies to both outcomes. Sirloin’s validator is a full JSON Schema compiler (santhosh-tekuri/jsonschema), so those conditionals are enforceable.

Sirloin keys its side effects on the verdict

The four behaviours listed above stop reading only terminal.State == "failed" and also read failure != null on a succeeded execution. A declined run still marks the upload deleted, still refuses to bind the asset, still leaves the session on the photo-validation stage with only its retry transition available, and still does not charge. What changes is the signal that triggers them, and that the execution row records a successful run.

Sirloin remains authoritative. The verdict decides what Sirloin does; it does not let the recipe or the browser decide.

The customer-facing explanation comes from the workflow

Brisket reads failure from the operation result, which already carries the committed execution output (apps/sirloin/internal/pkg/storage/characterexecution.go:729-732, apps/brisket/src/server/api/routers/characters-v2.ts:189-210). Stage renderers branch on a non-null failure and render failure.message.

Two constraints are load-bearing. Renderers must read the declared failure field and never the raw error key, which continues to carry unfiltered engine text; the safe_result_json field name describes intent, not a guarantee. And because the verdict is authored product copy, it is subject to the same review as any other customer-visible string.

Decline renders in place

A declined run leaves the session on the photo-validation stage. Sirloin marks the candidate deleted and exposes only the authored retry transition; Brisket renders the explanation inline and submits that transition when the customer chooses another photo (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:556-599, apps/brisket/src/features/characters-v2/views/creation/recipe-runner/stages/photo-validation/index.tsx). No new stage, show_when guard, or condition-resolver extension is required.

Conditions today resolve only against session values (apps/sirloin/internal/pkg/storage/charactersessionaction.go:744-783). Making a verdict reachable from a condition remains a bounded, reviewed extension of that resolver when a future flow needs it — not part of this decision. refreshCharacterExecutionTarget (apps/sirloin/internal/pkg/storage/characterexecutioncommit.go:604-612) stays on the success-and-accept path; it is not the decline rewind.

Scope

The contract applies to every CHARACTER_COMPUTE and DATASET_MANIPULATION workflow in the POC fixtures and their pinned recipes. Decorative status is removed everywhere it appears; failure is added everywhere those purposes declare an outputSchema. Published workflows are immutable, so each changed graph gets a new UUID and each recipe is repinned.

Consequences

Benefits

  • Workflow failure regains a single meaning, so operational dashboards separate outages from customers uploading unusable photographs.
  • A recipe can distinguish and explain any number of decline reasons without a new reason code being allowlisted in two places in Sirloin.
  • The explanation is authored next to the logic that produced it, so recipes sharing a workflow inherit the copy instead of each restating it.
  • No new transport, proto field, database column, or binding layer is required; the verdict rides the output contract and the operation result that already exist.
  • The enforced output_schema turns an otherwise loose renderer/graph coupling into a contract checked at commit.
  • Decline UI stays on the photo-validation stage, so recipes do not grow decline-specific stages or condition branches for this outcome.

Costs and risks

  • Terminal state stops being sufficient to understand a run. Anything reading succeeded must now also consider failure, and code that treats success as “the declared assets exist” needs revisiting.
  • Asset cardinality becomes conditional in exactly the workflows that can decline, which is easy to get wrong when authoring a pin.
  • Verdict copy is customer-visible text authored in a workflow graph, so graph review becomes copy review.
  • Migrating the fixtures is not mechanical: the graph, the pin, and the renderer change together, and published workflows are immutable, so each migration repins. All three POC recipes share some pins, so one graph change touches more than one recipe file.
  • A workflow that declines but forgets to say so now looks like a success. The output_schema requirement on failure is what prevents this and must not be omitted from a pin.

Rollback

  • Revert by repinning each recipe to the previous workflow UUID and digest, and restoring Sirloin/Brisket to key side effects off terminal.State == "failed".
  • Because the change spans graphs and recipe versions, roll back both together. Do not leave a failure-returning graph pinned by a recipe whose commit path still expects a failed execution for declines.

Alternatives Considered

  • Keep failing the execution, and add an authored-message channel beside error. Rejected. It carries product meaning on the fault path, so the operational conflation remains and every consumer must learn which of two error fields is safe to show. It was implemented far enough to confirm the plumbing works before being withdrawn on these grounds.
  • Name the field error instead of failure. Rejected. The webhook already injects error into the same output map for engine faults (apps/sirloin/internal/app/worker/braincharacterwebhook.go:273-275), so the authored decline and the unfiltered engine text would share a key.
  • Keep a decorative status string as the verdict. Rejected. Nothing reads it today; each purpose hardcodes a different constant with no enum; it would remain a false friend next to the real failure field.
  • Keep failing the execution, and map reason codes to copy in the recipe. Rejected. Recipes share workflows, so the same decline would be restated in every recipe that pins it, and the copy would drift from the logic that raises it.
  • Project workflow outputs into session values through declared output bindings. Rejected for now as premature. It duplicates state that already reaches the client and introduces a clearing problem — a stale message outliving its execution — of the kind that already forces failure_reason_code to be nulled in five separate places. Worth revisiting if conditions need to read verdicts routinely.
  • Add a declined stage guarded by show_when. Rejected for the first cut. Conditions resolve only against session values today, so routing off failure would require extending the resolver. Inline render on the photo-validation stage covers the product need without that change.
  • Let the renderer decide the outcome from the output and drop server involvement. Rejected. Deleting an upload, settling billing, and permitting progression are Sirloin invariants and cannot be decided in the browser.
  • Migrate only character.reference-validation first. Rejected. The decorative status field already spans every purpose in the fixtures, and a partial migration would leave half the pins speaking a contract the other half does not. The fixtures and recipes move together.