Study guides / CCAR-P / Domain 2

Solution Design & Architecture · Lesson 2 of 6

2.2 - End-to-End Architecture: Input, Processing, Output and Feedback Loops

Lay out a reference architecture for a Claude-based system, from intake through context assembly, model calls, output validation and delivery to a feedback loop, and decide which stage should catch which failure.

The model call is one box in a production system. Around it sit the stages that decide whether a request is allowed in, what the model sees, whether the answer is usable, where it goes, and how the next version gets better. Scenario questions about a "wrong" output usually hinge on one skill: identifying which stage should have caught the failure and putting the control there. This lesson gives you the reference architecture; lesson 2.3 covers the pattern inside the processing stage, lesson 1.4 and 3.6 cover observability and monitoring in depth, and lesson 4.1 covers guardrails.

The reference architecture

StageResponsibilityFailure it prevents
1. Intake and validationAuthenticate, check size and schema, apply rate limits and idempotency keys, decide the execution mode (interactive, queued, batch)Malformed, abusive or duplicate requests; unbounded spend
2. Context assemblyBuild system prompt, retrieved documents, memory, tool definitions and history in a deliberate orderMissing or wrong context; cache misses; injection through untrusted content
3. Model call(s)Run the chosen pattern with the chosen model, effort, token limits and timeoutsTimeouts, overload, runaway loops
4. Tools and actionsExecute tool calls, return results or errors, enforce permissionsDuplicate side effects; silent tool failure
5. Output validationCheck stop reason, parse, validate schema, business rules and groundingTruncated, malformed, wrong or ungrounded output reaching users
6. DeliveryStream, enqueue, write to the system of record, or hold for human reviewIrreversible action without review; wrong destination
7. Feedback and improvementLog, capture signals, triage failures, grow the eval set, gate releasesRecurring failures; silent regressions

Key concept: guarantees live in the code around the model

The model call is probabilistic. Every property you must be able to promise (schema, limits, permissions, idempotency, auditability) is enforced by a deterministic stage before or after it. When you are asked where to put a control, prefer the stage that can enforce it with code over an instruction inside the prompt.

Stages 1 and 2: intake and context assembly

Validate before you spend tokens. Anything that can be rejected with code should be. Also decide the execution mode here, because it shapes everything downstream: an interactive request streams a response to someone waiting; a queued job tolerates seconds to minutes; independent items with no latency need can go to the Message Batches API, which the documentation describes as roughly half the cost of standard calls, with most batches finishing within an hour and a 24-hour expiry. Batch results can return in any order, so the design must key on custom_id.

Context assembly is a design step, not string concatenation. Two rules matter architecturally. First, ordering: prompt caching reuses a matching prefix, and a change invalidates that level and everything after it in the order tools, system, messages. Put stable content (tool definitions, system prompt, reference material) first and per-request content last. Second, trust: web pages, inbound email, uploads and third-party API responses are untrusted. Anthropic's tool documentation recommends keeping such content inside tool_result blocks rather than in the system prompt or plain user text, and treating it as a potential indirect prompt injection vector. Retrieval, memory and context budgeting are covered in Domains 1 and 6.

Stages 3 and 4: the call and the tool loop

With client-executed tools your application owns the loop: send the request, and while stop_reason is tool_use, execute each tool and send back tool_result blocks; any other stop reason ends the loop. The tool results must immediately follow the assistant's tool_use message and come first in the user content. A failed tool returns its message with is_error: true, and the documentation advises instructive messages (what failed and what to try next) so the model can recover. Architecturally, add what the API does not: a maximum iteration count or budget, permission checks per tool, and idempotency for anything with side effects.

Separate transient from persistent failures. The official SDKs retry connection errors, rate limits and 5xx errors with exponential backoff (twice by default) and honor retry-after. A 429 caused by hitting a spend cap has no retry-after and keeps failing, so blind retry there is wrong; it needs an alert and a degraded mode. For long generations the docs recommend streaming or batches instead of one very long blocking request. Every response carries a request-id header; log it, because it is the handle support and your own traces need.

Stage 5: output validation in layers

Treat validation as four layers, cheapest first:

  1. Stop reason. max_tokens means the response was truncated, so never parse it as complete. refusal means Claude declined; the docs point you to stop_details and to retrying on a fallback model. model_context_window_exceeded also means truncation, and pause_turn means a server-tool loop needs to be continued.
  2. Schema. Structured outputs (output_config.format with a JSON schema) and strict tool use (strict: true) use constrained decoding so the result conforms to your schema. That is a guarantee about shape, not truth. The documentation lists unsupported constraints such as numeric minimum and maximum and string length limits, and notes that refusals and max_tokens can still yield non-conforming output.
  3. Business rules in code. Totals equal the sum of the lines, IDs exist in the system of record, dates fall in range, cited passages exist in the retrieved set. These cross-field checks cannot be expressed in the schema and must not be left to the prompt.
  4. Semantic or subjective quality. LLM-graded checks or sampled human review for tone, completeness and faithfulness (Domain 3 and lesson 4.3).

Decide the failure policy up front: a bounded repair attempt that feeds the specific error back, then escalation to a review queue or a safe fallback. Never emit a failed result silently, and never loop unbounded.

Common exam distractor

"Turn on structured outputs and the problem is solved" and "tell the model to always return consistent totals" are both attractive and both wrong when the defect is a value that is well-formed but incorrect. Schema conformance is not correctness, and a prompt instruction is not enforcement. The answer that adds a deterministic validation stage with a defined failure path is the one that holds up. Equally, cost levers (batching, caching) and token limits do not fix correctness defects.

Stages 6 and 7: delivery and the feedback loop

Delivery is where reversibility matters. Make writes idempotent so a retried job does not act twice, and put human review before actions that cannot be undone (lesson 4.3 covers where to place gates).

The feedback loop is closed only when a production failure becomes a test that gates the next release. Capture, per request: the request ID, the prompt and configuration version, model, token usage, latency, stop reason, validation outcome and final disposition, plus whatever human signal exists (an edit, a rejection, a rating). Then triage regularly and convert real failures into eval cases; Anthropic's guidance on agent evals is to start from what you already test manually and turn user-reported failures into test cases. Run regression evals before any prompt or model change, and combine them with production monitoring and human review, because no single layer catches every issue. Logging prompts means logging personal data, so retention and access decisions belong in this stage (lessons 1.4 and 4.4).

A dashboard of thumbs-up rates is not a feedback loop. It measures sentiment; it does not change what the system will do tomorrow.

Exam traps

Practice question

An invoice-processing pipeline uses structured outputs to extract vendor, line items and total. Every response parses and passes the JSON schema, and stop_reason is end_turn. Yet the downstream ERP occasionally rejects records because the total does not equal the sum of the line items. Which change best fits an end-to-end architecture view?

  • A Tighten the JSON schema with numeric minimum and maximum constraints and stricter field types so that incorrect totals cannot be generated by the model

    Numeric range constraints are not among the schema features structured outputs supports, and no range constraint can express that one field must equal the sum of others. This is a cross-field rule that belongs in code.

  • B Increase max_tokens substantially so the model has room to finish its arithmetic instead of being cut off mid-calculation and returning an inconsistent total

    The responses already complete with end_turn and parse correctly, so truncation is not the defect. A larger limit does not make values consistent.

  • C Move the pipeline to the Message Batches API to reduce cost, on the basis that giving each request more time to run lets the model reason more carefully about the totals

    Batching is a cost and throughput lever. It does not give the model extra reasoning time per request and does nothing about an internal inconsistency in the extracted values.

  • D Add a deterministic check after parsing (total equals sum of lines, vendor exists), one bounded repair retry with the error fed back, then escalation to review and logging of the failure as an eval case Correct

    It puts the control in the stage that can enforce it, defines a failure path instead of emitting bad records, and closes the loop by turning failures into regression tests.

Build exercise: Build a validated pipeline with a closed feedback loop

Intermediate · 90 minutes

You'll practice:

  1. Pick a use case (invoice extraction, ticket triage, contract clause tagging) and write a table of the seven stages: what your system does at each one and the specific failure it is there to catch.

    The exam skill is locating the right stage for a control. Writing the table forces you to assign every requirement (schema, permissions, idempotency, audit) to a deterministic stage or to an explicit model behaviour.

    You should see: A seven-row table where no row says only 'call Claude', and where at least three controls are deterministic code rather than prompt text.

    Hints
    1. For each requirement you must always meet, could a sentence in a prompt actually guarantee it?
    2. List the hard requirements first (never act twice, never exceed the limit, always valid shape) and assign each to a code stage; then list soft qualities (tone, completeness) and assign them to evals or review.
    3. Invoice example: intake rejects files over a size limit and duplicate invoice IDs; context puts the extraction instructions first and the document last; call uses structured outputs; validation checks total equals sum of lines and vendor exists; delivery writes idempotently by invoice ID; feedback logs failures to failures.jsonl.
  2. Implement the model call with a JSON schema through output_config.format, and reject any response whose stop_reason is not end_turn before parsing.

    Stop reason is the cheapest validation layer. A truncated or refused response must never be parsed as complete.

    You should see: A function that returns parsed data for a normal call and raises a clear error for max_tokens or refusal responses.

    Hints
    1. Which stop reasons mean the text you are about to parse might be incomplete or absent?
    2. Check response.stop_reason before json.loads, and keep the schema strict with additionalProperties false.
    3. import anthropic, json
      client = anthropic.Anthropic()
      MODEL = 'claude-sonnet-5'  # check the models overview page for current IDs
      SCHEMA = {'type': 'object', 'properties': {'vendor': {'type': 'string'}, 'lines': {'type': 'array', 'items': {'type': 'object', 'properties': {'desc': {'type': 'string'}, 'amount': {'type': 'number'}}, 'required': ['desc', 'amount'], 'additionalProperties': False}}, 'total': {'type': 'number'}}, 'required': ['vendor', 'lines', 'total'], 'additionalProperties': False}
      def extract(text):
          r = client.messages.create(model=MODEL, max_tokens=1024, messages=[{'role': 'user', 'content': text}], output_config={'format': {'type': 'json_schema', 'schema': SCHEMA}})
          if r.stop_reason != 'end_turn':
              raise RuntimeError('unusable response: ' + str(r.stop_reason))
          text = next(b.text for b in r.content if b.type == 'text')
          return json.loads(text), r
  3. Write a deterministic business-rule validator and a bounded repair loop: one repair attempt that feeds the specific validation error back, then return a needs_review result instead of bad data.

    Cross-field rules cannot live in the schema. A bounded repair with the concrete error is a cheap first recovery, and the escalation path prevents both silent bad output and infinite retry.

    You should see: Clean invoices return status ok; an invoice you deliberately corrupt returns needs_review with the error list after at most two model calls.

    Hints
    1. What is the rule the schema cannot express, and what should happen when the repair also fails?
    2. Implement check(d) returning a list of error strings, loop for max_repairs + 1 attempts appending the errors to the prompt, and return needs_review at the end.
    3. def check(d):
          errs = []
          if abs(sum(l['amount'] for l in d['lines']) - d['total']) > 0.01:
              errs.append('total does not equal the sum of line amounts')
          return errs
      def process(text, max_repairs=1):
          prompt = text
          for _ in range(max_repairs + 1):
              d, r = extract(prompt)
              errs = check(d)
              if not errs:
                  return {'status': 'ok', 'data': d}
              prompt = text + '\n\nYour previous answer failed validation: ' + '; '.join(errs) + '. Re-extract carefully.'
          return {'status': 'needs_review', 'data': d, 'errors': errs}
  4. Add a per-request log record with request ID, prompt version, model, input and output token counts, latency, stop reason, validation outcome and final status, appended as one JSON line.

    Without this record you cannot diagnose a failure, attribute cost, or build eval cases from production. It is the raw material of the feedback loop.

    You should see: A requests.jsonl file with one line per call, including the request ID you could quote to support.

    Hints
    1. If someone reports a bad output tomorrow, what would you need to reconstruct what happened?
    2. The Python SDK exposes response._request_id and response.usage; add timing around the call and write a dict per attempt.
    3. import time
      def logged_extract(text, prompt_version='v1'):
          t0 = time.time()
          d, r = extract(text)
          rec = {'request_id': r._request_id, 'prompt_version': prompt_version, 'model': MODEL, 'input_tokens': r.usage.input_tokens, 'output_tokens': r.usage.output_tokens, 'latency_s': round(time.time() - t0, 2), 'stop_reason': r.stop_reason}
          with open('requests.jsonl', 'a') as f:
              f.write(json.dumps(rec) + '\n')
          return d, r
  5. Close the loop: write each needs_review case to failures.jsonl with its input and the reason, then write a regression script that replays failures.jsonl and fails the build if more than an agreed number still end in needs_review.

    A feedback loop is closed when failures become tests that gate the next change. This is the difference between monitoring and improvement.

    You should see: After you change the prompt, running the regression script reports how many previously failing cases now pass, and exits non-zero when the gate is not met.

    Hints
    1. What happens to a failure today after the reviewer fixes it by hand?
    2. Store the input with a 'reason' field, then replay all stored inputs through process() and count non-ok results against a threshold you choose in advance.
    3. import sys
      ALLOWED = 0  # agreed in advance
      cases = [json.loads(l) for l in open('failures.jsonl')]
      bad = [c for c in cases if process(c['input'])['status'] != 'ok']
      print(f'{len(cases) - len(bad)}/{len(cases)} previously failing cases now pass')
      sys.exit(1 if len(bad) > ALLOWED else 0)

Sources