StrategyThread

Guides / StrategyThread

Design an AI workflow with typed outputs and explicit failure states

A practical pattern for AI workflows that use typed outputs, explicit failure states, and tests to reject malformed or weakly supported results.

November 5, 2026 · 12 min read · StrategyThread

Consider a hypothetical research intake pipeline that fails without throwing an error. A teammate pastes a dense note, the model returns something that looks like JSON, your parser accepts it, and a later step treats the output as if it were verified. The problem may not be missing braces. It may be a neat-looking record with one invented category, one unsupported summary, or one extra field that your downstream code quietly ignores.

This guide proposes designing two things separately:

Use those two records to distinguish format validation from source review. Structured output constraints help with shape, but they do not automatically prove that the content is well-supported. OpenAI’s Structured Outputs guide says Structured Outputs constrains responses to your supplied JSON Schema and describes a separate refusal indicator that application code can inspect OpenAI Structured Outputs guide. Those are useful guarantees for format and refusal handling, not for truth or business suitability.

This article gives a practical design you can adapt for extraction, research intake, triage, and review queues. The worked example uses hypothetical rules and synthetic data, labeled as such.

Separate payload outcomes from workflow failures

The first design move is conceptual: stop putting every possible condition into one status field.

A suggested heuristic is to define two layers of state.

Layer 1: payload outcomes

These are values the model may return inside the schema after a successful structured generation. Keep them tightly tied to the task.

For a research intake record, one compact set is:

Those values mean the model produced a valid payload for the task, even if the payload says the source note was not usable.

Layer 2: outer workflow failures

These are states your application assigns outside the schema when the run did not produce a usable typed payload.

Suggested workflow states:

These names are editorial recommendations, not source-defined platform terms. In this proposed design, use the labels as follows:

The Structured Outputs guide includes examples of handling refusal via message.refusal, and it shows an example that checks finish_reason for a length-based incomplete result path OpenAI Structured Outputs guide. That supports treating refusal and incompletion as distinct conditions, even if your own workflow labels are application-defined.

For this proposed design, do not let the model return schema_error or request_error inside the payload. Those are orchestration concerns, not task outcomes.

Design the schema from downstream needs

A typed workflow starts with the consumer of the data, not with the prompt.

Ask these questions first:

OpenAI’s Structured Outputs documentation shows strict JSON Schema usage, including required fields and additionalProperties: false, and it notes SDK support for typed schema definitions with Python pydantic.BaseModel and JavaScript z.object OpenAI Structured Outputs guide. That is the technical basis for treating the schema as a contract rather than as a formatting wish.

Here is a hypothetical schema for a research intake record:

{
  "type": "object",
  "properties": {
    "status": {
      "type": "string",
      "enum": ["accepted", "needs_review", "rejected_input"]
    },
    "topic": { "type": "string" },
    "claim_summary": { "type": "string" },
    "evidence_items": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "source_text": { "type": "string" },
          "supports_claim": { "type": "boolean" }
        },
        "required": ["source_text", "supports_claim"],
        "additionalProperties": false
      }
    },
    "reason": { "type": "string" }
  },
  "required": ["status", "topic", "claim_summary", "evidence_items", "reason"],
  "additionalProperties": false
}

Why this shape is useful:

A suggested heuristic is to avoid a free-form confidence field unless you already know how it will be used. In many workflows, explicit evidence checks are easier to review than an ungrounded score.

Build failure handling before the happy path

Define what the application does with each failure before adding the successful-response path.

Start with a decision procedure.

Check orderConditionSuggested workflow stateSuggested next action
1Safety refusal detectedrefusalSurface clearly; do not coerce into a normal payload
2Response stopped before usable completionincomplete_responseRetry under controlled rules or fail closed
3Output fails schema validationschema_errorRecord a redacted diagnostic and isolate the run
4Typed payload parsedparsedApply business rules
0: before output checksAPI/request failedrequest_errorHandle the transport error without treating it as a payload

Then apply payload-level rules only after parsing succeeds.

Suggested payload interpretation table:

Payload statusMeaningSuggested action
acceptedStructured record appears sufficiently supportedPass downstream
needs_reviewRecord is structured but support is weak, narrow, or ambiguousQueue for human review
rejected_inputSource material is too thin or unsuitable for extractionStop and request better input

Use the two stages to distinguish infrastructure and format failures from a task-level rejection. If you blur refusal, truncation, schema failure, and business rejection together, your logs stop being diagnostic. If you separate them, you can answer basic operational questions: did the model decline, did the payload fail validation, or did the content simply not meet your own criteria?

A fully worked example: research intake with support checks

This section is a hypothetical example with synthetic data.

Step 1: define the acceptance rules

Suggested heuristics for this example:

These thresholds are recommendations for illustration, not source-proven universal outcomes.

Step 2: synthetic source note

The team wants a workflow for screening incoming AI research notes.
The extracted result must be easy for software to parse.
Unsupported claims should be stopped before they reach later stages.

Step 3: synthetic structured payload

{
  "status": "accepted",
  "topic": "AI research intake workflow",
  "claim_summary": "The note describes a parsable research intake workflow that should block unsupported claims from moving forward.",
  "evidence_items": [
    {
      "source_text": "The extracted result must be easy for software to parse.",
      "supports_claim": true
    },
    {
      "source_text": "Unsupported claims should be stopped before they reach later stages.",
      "supports_claim": true
    }
  ],
  "reason": "Two source statements directly support the summary."
}

Step 4: verify the result

Check the result in two passes.

Pass A: schema checks

Pass B: business checks

For this synthetic example, independently read the two snippets against the summary. One states the parsing requirement and the other states the unsupported-claim requirement. They establish what the note requests, not that the proposed system is effective. Both snippets occur in the note, so the verified support count is 2 and accepted is consistent with the proposed rule. A model-generated supports_claim: true is not itself that independent check.

Step 5: apply outer workflow logic

Handle the actual SDK's request error, refusal, completion and schema-validation paths before calling the following application code. The documented message.refusal and finish_reason examples use a Chat Completions response; adapt those checks to the endpoint you actually call rather than inventing a shared response envelope.

This runnable helper implements only the proposed post-parse source-support rule:

def normalize_status(support_count, input_status):
    allowed = {"accepted", "needs_review", "rejected_input"}
    if type(support_count) is not int or support_count < 0:
        raise ValueError("invalid support count")
    if input_status not in allowed:
        raise ValueError("invalid payload status")
    if support_count == 0 or input_status == "rejected_input":
        return "rejected_input"
    if support_count == 1:
        return "needs_review"
    return input_status


def apply_reviewed_support(obj, note, reviewed_indices):
    # Call only after schema validation and an independent source review.
    # reviewed_indices comes from that review, never from the model's votes.
    verified = {
        item["source_text"]
        for index, item in enumerate(obj["evidence_items"])
        if index in reviewed_indices
        and item["source_text"].strip()
        and item["source_text"] in note
    }
    count = len(verified)
    return {
        **obj,
        "status": normalize_status(count, obj["status"]),
        "reason": f"Application check: {count} distinct reviewed snippets.",
    }

For the displayed payload, the completed source review identifies indices {0, 1}. Calling apply_reviewed_support(payload, note, {0, 1}) retains accepted and records two distinct reviewed snippets. With no verified snippets, even a model-labeled accepted payload becomes rejected_input. Duplicate text does not increase the count. The helper checks text presence; the independent review must still establish semantic support.

The design point is the order:

Test both your code and your model behavior

OpenAI’s evals guide describes a three-part loop: describe the task, run test inputs, then analyze results and iterate OpenAI evals guide. The guide recommends checking performance against your criteria when a model or other application setting changes OpenAI evals guide.

That suggests two distinct testing layers.

Layer 1: deterministic tests for your own logic

Your status normalization, schema handling, and rule checks should be tested without live model calls.

Python’s unittest documentation describes fixtures, test cases, suites, runners, and discovery as core parts of the standard library testing framework Python unittest guide. If you want concise test matrices, pytest.mark.parametrize runs the same test function with multiple argument sets pytest parametrization guide.

Example:

import unittest


class SupportRulesTests(unittest.TestCase):
    def test_status_matrix(self):
        cases = [
            (0, "accepted", "rejected_input"),
            (1, "accepted", "needs_review"),
            (2, "accepted", "accepted"),
            (0, "needs_review", "rejected_input"),
            (1, "needs_review", "needs_review"),
            (2, "needs_review", "needs_review"),
            (0, "rejected_input", "rejected_input"),
            (1, "rejected_input", "rejected_input"),
            (2, "rejected_input", "rejected_input"),
        ]
        for count, status, expected in cases:
            with self.subTest(count=count, status=status):
                self.assertEqual(normalize_status(count, status), expected)

    def test_duplicate_snippets_count_once(self):
        obj = {"status": "accepted", "topic": "Synthetic test",
               "claim_summary": "A sample claim", "reason": "Synthetic input",
               "evidence_items": [
            {"source_text": "Use typed records.", "supports_claim": True},
            {"source_text": "Use typed records.", "supports_claim": True},
        ]}
        result = apply_reviewed_support(obj, "Use typed records.", {0, 1})
        self.assertEqual(result["status"], "needs_review")

    def test_unreviewed_or_missing_snippets_do_not_pass(self):
        obj = {"status": "accepted", "topic": "Synthetic test",
               "claim_summary": "A sample claim", "reason": "Synthetic input",
               "evidence_items": [
            {"source_text": "Not present in the source.", "supports_claim": True},
        ]}
        result = apply_reviewed_support(obj, "Use typed records.", {0})
        self.assertEqual(result["status"], "rejected_input")
        unreviewed = apply_reviewed_support(obj, "Not present in the source.", set())
        self.assertEqual(unreviewed["status"], "rejected_input")

    def test_invalid_count(self):
        for value in [-1, True, "2"]:
            with self.assertRaises(ValueError):
                normalize_status(value, "accepted")


if __name__ == "__main__":
    unittest.main()

A suggested heuristic is to keep these tests fully deterministic. If a test is verifying your own rules, do not let model variation into the test.

Layer 2: eval-style cases for model behavior

Build a small corpus of representative task inputs. Record actual model responses and independently reviewed expected outcomes. The table below lists conditions the complete workflow must handle; it does not promise that every safety-related topic produces a refusal or that every short token budget produces truncation.

CaseInput or response conditionExpected workflow stateExpected payload status
Amodel-labeled accepted with two verified snippets and no other holdparsedaccepted
Bmodel-labeled accepted with only one verified snippetparsedneeds_review
Cno verified supporting snippetparsedrejected_input
Dendpoint supplies its explicit refusal indicatorrefusaln/a
Eresponse stops before usable completionincomplete_responsen/a
Fmalformed or extra-key outputschema_errorn/a

This does not prove universal reliability. It gives you a repeatable way to notice regressions when you change prompts, schemas, or model versions.

Reusable checklist, assumptions, and common mistakes

A StrategyThread-style editorial workflow is a good fit for this pattern because the site presents itself as AI automation and SaaS strategy research with articles arranged into a connected knowledge structure across multiple content buckets StrategyThread. For this proposed intake method, retain the evidence separately so a reviewer can compare it with the conclusion.

Use this suggested pre-ship checklist:

CheckWhat to verify
Downstream consumer definedEvery field exists for a known later step
Schema narrow enoughRequired keys, enums, and extra-key policy are intentional
Evidence separated from summaryReviewers can inspect support directly
Payload and workflow states separatedRefusal and parse failures are not mixed into business outcomes
Rule checks implemented in codeYou are not relying on prompting alone
Failure paths testedRefusal, incompletion, malformed output, and weak evidence are covered
Logs are reviewableEach run records workflow state, payload status, and reason
Change procedure definedPrompt, model, or schema changes trigger reruns of tests and evals

Assumptions behind this approach:

Limits:

Common mistakes:

Before connecting the workflow to a later action, decide which states can proceed and test the paths that must stop. Keep the parsed payload, independent source check and resulting decision together.