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 | nullwhere 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:
| Surface | Trust | Role |
|---|---|---|
operation.result.failure | Authored product copy, schema-validated | Customer-visible explanation of a successful decline |
session.failureReasonCode | Allowlisted machine code on the session row | Narrow 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_schematurns 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
succeededmust now also considerfailure, 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_schemarequirement onfailureis 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
errorinstead offailure. Rejected. The webhook already injectserrorinto 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
statusstring 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 realfailurefield. - 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_codeto 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 offfailurewould 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-validationfirst. Rejected. The decorativestatusfield 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.