Batch and Review: Matching the Shape of the Work

Two ways to match an architecture to a workload: what batch processing is for and the trade-offs that decide when it fits (cheaper, up to 24 hours, no tool loop), and why an independent reviewer and per-file/cross-file passes catch what a single self-review misses.

Ten thousand documents need classification by morning, while a developer is waiting for one pull-request review before merging. These workloads need different request paths. The document job can collect results later; the review needs timely findings and enough context to distinguish a bug from an intentional design choice.

Synchronous, streaming and batch workloads are selected by who is waiting; a batch lifecycle and three complementary review patterns follow.

Exam objectives covered here. 4.5 Design efficient batch processing strategies. 4.6 Design multi-instance and multi-pass review architectures. Numbering follows the Claude Certified Architect – Foundations Exam Guide, version 1.0, effective July 2026.

Three shapes, not two

Choose the request path by who needs the result and when:

Synchronous. Send a request, block, get an answer. Right when a human or a pipeline needs the result now, and the whole result at once.

Streaming. Send a request and receive the answer in pieces as it is generated. Right when a human is watching, because time-to-first-token matters more than time-to-last-token. client.messages.stream is a first-class helper in anthropic 0.120.0, and it is the shape a chat interface uses.

Batch. Hand over a pile of independent requests as one asynchronous job and collect the results later. Right when nobody is waiting on any particular one.

Ten thousand documents to classify overnight, a weekly audit, a nightly test-generation run. Firing those synchronously means babysitting throughput for work whose deadline is “by morning.” Streaming them is worse than pointless: there is no one to watch the tokens arrive.

When batch fits, and when it doesn’t

One constraint decides it: a batch may take up to 24 hours to complete. Batch is for non-blocking, latency-tolerant workloads: overnight reports, weekly audits, nightly test generation, anywhere “sometime in the next several hours” is fine. It is wrong for blocking workflows. A pre-merge CI check cannot wait up to 24 hours for a review, so it must use the synchronous API. The clean split: synchronous for blocking (pre-merge checks), batch for overnight and weekly analysis, streaming when a person is watching.

What the Batches API actually gives you

The Message Batches API submits independent requests for later processing. In anthropic 0.120.0, client.messages.batches exposes six methods:

['cancel', 'create', 'delete', 'list', 'results', 'retrieve']

delete is the one that goes missing from summaries. It matters for a batch of documents you would rather not leave addressable on a vendor’s side once you have collected the results. It also carries a caveat about what deletion means, which the lifecycle section below spells out.

Three trade-offs decide whether batch fits:

  • A hard 24-hour ceiling. This one is in the API surface, not just the marketing. MessageBatch.expires_at is documented as “the time at which the Message Batch will expire and end processing, which is 24 hours after creation.” There is no field anywhere promising when within that window a batch finishes. That is the precise form of “no latency SLA”: you get a ceiling, not a schedule.
  • A lower price per token. Usage.service_tier reports "standard", "priority", or "batch", so the tier is real and observable on the response. The size of the discount is a pricing fact from Anthropic’s documentation, not something measured in this book. Pricing changes, so check the current rate rather than carrying a number in your head.
  • custom_id on every request, because results come back out of order. The field’s own documentation says so: “results may be given out of request order. Use the custom_id field to match results to requests.”

Choosing the custom_id

It is the only piece of your data model the batch API knows about, so it has to let your application reconnect each result to its input. That makes it worth thirty seconds of thought rather than an f-string.

The type is a bare str, and the SDK states exactly one rule: it “must be unique for each request within the Message Batch.” Note what that does and does not say. Uniqueness is scoped to the batch, not to your system. Two batches may reuse the same id, so your storage has to key on (batch_id, custom_id) rather than custom_id alone.

Any length limit or character restriction is a server-side constraint the SDK type does not express. Treat “it is a string” as the wire contract, and check the current API reference before assuming a 200-character key or a colon will be accepted. If you generate ids from data you do not control, cap and sanitise them yourself.

The more consequential point is what you put in it. The custom_id travels to the vendor, is stored with the batch, and comes back in the results file. It is the one field in this design that leaks by construction. And the temptation is to make it descriptive:

{"custom_id": f"invoice-{customer.email}-{invoice.number}"}   # don't
{"custom_id": f"inv-{uuid4().hex}"}                           # opaque

The first one puts a customer’s email address into a third-party system for no benefit at all. You are going to look the row up in your own database anyway. So use an opaque key and keep the mapping local:

handles = {f"inv-{uuid4().hex}": invoice.id for invoice in invoices}

That mapping is now the thing you must persist, at creation time, alongside the batch id. Losing it turns a completed batch into a file of answers with nothing to attach them to. Which is a good argument for a database row rather than a dictionary in the process that happens to be polling.

What a batch item may and may not contain

A batch item can request tool-shaped output, but it does not execute a client-side tool loop.

A batch Request’s params field is typed MessageCreateParamsNonStreaming. Its keys are:

['cache_control', 'container', 'inference_geo', 'max_tokens', 'messages',
 'metadata', 'model', 'output_config', 'service_tier', 'stop_sequences',
 'stream', 'system', 'temperature', 'thinking', 'tool_choice', 'tools',
 'top_k', 'top_p', 'user_profile_id']

tools is right there, and so are tool_choice, thinking, system, temperature and output_config. A batch item accepts tool definitions, and a batch item can return a tool_use block. Chapter 10’s forced-extraction pattern works inside a batch unchanged, which is exactly what you want for ten thousand structured extractions.

What a batch cannot do is run the loop. The agentic loop requires a client that receives the tool_use block, executes the tool, and sends a tool_result back on a second request. A batch item is one request. There is no client sitting between the model and the tool. The tool_use block comes back to you hours later as data, and any tool you were going to run has to be run by your own code afterwards. The precise claim is:

Tool definitions are accepted and a tool_use block can be returned. Multi-turn tool execution inside a single batch item is not possible, because nothing there executes the tool and continues the turn.

Forced-tool extraction can therefore use batch processing. A multi-turn agent that must execute a tool and reason from its result needs a caller to run those additional steps.

The stream key is the other tell. Its type in MessageCreateParamsNonStreaming is Literal[False], not bool — the type system says out loud that a batch item cannot stream. Which is exactly right. Streaming exists to serve a human watching tokens arrive, and a batch has no viewer. The three shapes are mutually exclusive by construction, not by convention.

A batch, end to end

Create the batch, poll until it ends, collect every result, and classify the items that need another attempt.

Create. Each entry is a Request with exactly two keys: a custom_id you choose, and params that are ordinary Messages API parameters.

import anthropic
client = anthropic.Anthropic()

RUBRIC = "Classify the ticket. Reply: SEVERITY: <high|medium|low> | CATEGORY: <word>"

batch = client.messages.batches.create(requests=[
    {
        "custom_id": f"ticket-{t['id']}",          # yours; must be unique in the batch
        "params": {
            "model": "claude-haiku-4-5",
            "max_tokens": 64,
            "system": RUBRIC,
            "messages": [{"role": "user", "content": t["body"]}],
        },
    }
    for t in tickets
])
print(batch.id, batch.processing_status, batch.expires_at)

Submitting three items returns immediately, and the object it hands back already tells you two useful things:

created            : msgbatch_0188fc99CMn5JshoUGeRV7vj
  processing_status: in_progress
  created_at       : 2026-09-04 08:14:44.064948+00:00
  expires_at       : 2026-09-05 08:14:44.064948+00:00
  request_counts   : (canceled=0, errored=0, expired=0, processing=3, succeeded=0)
  results_url      : None

The 24-hour window is not a documentation claim you have to take on trust — it is expires_at minus created_at, to the microsecond, on the object you are holding. And results_url is None until the batch ends, so it doubles as a readiness flag.

Poll. processing_status is a three-member literal, and the middle one surprises people:

Literal["in_progress", "canceling", "ended"]

There is no "failed" and no "succeeded". A batch ends, and how it went is a tally in request_counts, which has five integer fields: processing, succeeded, errored, canceled, expired. Four of those five are documented as “zero until processing of the entire Message Batch has ended”. A mid-flight poll therefore shows everything sitting in processing — and tells you nothing about how many will succeed. Polling the three-item batch above twenty-five minutes after submission returns exactly that:

in_progress | (canceled=0, errored=0, expired=0, processing=3, succeeded=0)

Three still processing, nothing succeeded, and no way to tell from those numbers whether the batch is one minute or twenty hours from finishing. Do not build a progress bar on the success count.

That batch ended after about eleven minutes of polling, and the ended state is where the counts finally mean something:

ended
  request_counts : (canceled=0, errored=1, expired=0, processing=0, succeeded=2)
  results_url    : set

  ticket-002       succeeded  text='BETA'   stop=end_turn  service_tier=batch
  ticket-001       succeeded  text='ALPHA'  stop=end_turn  service_tier=batch
  ticket-003-bad   errored    InvalidRequestError: max_tokens: 999999 > 64000,
                              which is the maximum allowed number of output tokens

The tier is observable. service_tier reads batch on the succeeded messages, so you can confirm from the response which pricing path a request actually took rather than inferring it from how you submitted.

A malformed item fails alone. The third request asked for more output tokens than the model allows. It did not fail the batch or block the other two — it came back as an errored row carrying its own exception, while ticket-001 and ticket-002 succeeded normally. Per-item isolation is the property that makes the resubmit loop worth writing.

The results came back in a different order than they went in. Submitted ['ticket-001', 'ticket-002', 'ticket-003-bad'], returned ['ticket-002', 'ticket-001', 'ticket-003-bad']. Nothing is wrong; the API makes no ordering promise. But a loop that pairs results() positionally against the list it submitted will silently attach every answer to the wrong document, and with three tickets whose text is ALPHA and BETA you would notice, while with forty thousand invoices you would not. Match on custom_id. This is the whole reason the field is required.

Handle every non-success type. Alongside succeeded, the result union includes errored, canceled and expired. Inspect an error before deciding whether to retry or repair its request. A canceled or expired item can be resubmitted when the work is still wanted and the remaining deadline permits it.

The loop that does the polling is the piece most often written as four lines and shipped, and four lines is not enough. while True: sleep(60) has no deadline, so a batch that never ends holds a process open indefinitely. It has no jitter, so ten workers started by the same deployment poll in lockstep forever. And it treats a transport error as fatal when it is the most ordinary thing that can happen over twenty-four hours.

import random, time
from datetime import datetime, timezone
from anthropic import APIConnectionError, APIStatusError

MAX_INTERVAL = 300.0        # never sleep longer than five minutes
MAX_POLL_ERRORS = 5         # consecutive transport failures before giving up

def wait_for(batch, *, deadline: datetime, log):
    """Poll until the batch ends, our deadline passes, or the API stops answering."""
    interval, errors = 5.0, 0

    while True:
        try:
            batch = client.messages.batches.retrieve(batch.id)
        except (APIStatusError, APIConnectionError) as e:
            errors += 1
            if errors >= MAX_POLL_ERRORS:
                raise PollingFailed(batch.id) from e
            log.warning("poll_failed", batch=batch.id, attempt=errors)
        else:
            errors = 0
            log.info("poll", batch=batch.id, status=batch.processing_status,
                     counts=batch.request_counts.model_dump())
            if batch.processing_status == "ended":
                return batch                        # the only terminal state
            # "canceling" is not terminal. Someone asked it to stop; it still
            # has to reach "ended" before results_url exists. Keep polling.

        now = datetime.now(timezone.utc)
        stop_at = min(deadline, batch.expires_at)
        if now >= stop_at:
            client.messages.batches.cancel(batch.id)   # stop paying for it
            raise DeadlineExceeded(batch.id, batch.request_counts)

        # bounded exponential backoff with full jitter, clipped to the deadline
        interval = min(interval * 2, MAX_INTERVAL)
        time.sleep(max(1.0, min(random.uniform(0, interval),
                                (stop_at - now).total_seconds())))

batch = wait_for(batch, deadline=submitted_at + timedelta(hours=6), log=log)
c = batch.request_counts
print(f"succeeded={c.succeeded} errored={c.errored} "
      f"canceled={c.canceled} expired={c.expired}")

The poller has a deadline, cancellation, jitter, a consecutive-error limit, and an explicit terminal-state check. Its logs let you reconstruct the wait.

The deadline is yours, and it is not expires_at. The API’s ceiling is twenty-four hours; your promise to whoever is waiting is usually a lot shorter. stop_at takes the earlier of the two, so the loop gives up when your commitment is broken rather than when the vendor’s window closes. Passing the deadline in as an argument rather than computing it inside is what makes the function testable.

Missing the deadline triggers a cancel. A batch you have stopped waiting for is still running. It is still accruing charges for work whose result you have already decided not to use. cancel is the only thing that stops that, and it is what makes DeadlineExceeded a decision rather than an abandonment.

Full jitter, not a fixed interval. random.uniform(0, interval) rather than interval is the difference between clients spreading out and clients synchronising. Fixed-interval polling from a fleet produces a request spike on a period, and every retry after a blip re-aligns them. The cap at five minutes keeps the backoff from growing until the poll is less frequent than the thing it is watching.

Transport failures are counted, not fatal. One 503 in a six-hour poll is noise. Five in a row is a signal. Resetting the counter on success is what distinguishes the two, and it is the line people forget.

"canceling" is handled explicitly, and it is not terminal. The tempting shape is if status != "in_progress": break. That exits into a state where results_url is still None and the counts are still zeros. There is exactly one terminal status — ended — and the loop should say so out loud.

Record status and counts on each poll. Those timestamps help explain a long wait or a sequence of transport failures.

Iterate. results() returns a decoder over one MessageBatchIndividualResponse per line. Each carries the custom_id you supplied, plus a result that is a four-way union discriminated on type:

MessageBatchResult = Union[MessageBatchSucceededResult,   # type="succeeded", .message
                           MessageBatchErroredResult,     # type="errored",   .error
                           MessageBatchCanceledResult,    # type="canceled"
                           MessageBatchExpiredResult]     # type="expired"

Only the first two carry a payload. canceled and expired are bare markers: the request was never attempted, and there is nothing to inspect. Code that assumes success and reaches for .message breaks on three of the four branches.

done, retry = {}, []

for row in client.messages.batches.results(batch.id):
    r = row.result
    if r.type == "succeeded":
        msg = r.message
        if msg.stop_reason == "end_turn":
            done[row.custom_id] = msg.content[0].text
        else:
            retry.append((row.custom_id, msg.stop_reason))   # max_tokens, refusal…
    elif r.type == "errored":
        retry.append((row.custom_id, r.error.error.type))
    else:                                    # "canceled" or "expired"
        retry.append((row.custom_id, r.type))

Note the inner check. A succeeded result means the request completed, not that the answer is usable. Chapter 10’s stop_reason gate applies to every message in a batch exactly as it does to a synchronous one. A batch item truncated at max_tokens is reported as a success.

Resubmit the failures, and only the failures. This is where custom_id earns its place. It is the key that maps a failed result back to the original input. So you can rebuild a new batch containing only the items that need it — each with the appropriate fix.

fixes = []
for cid, reason in retry:
    original = tickets_by_id[cid]
    if reason == "max_tokens":
        fixes.append(request_for(original, max_tokens=256))
    elif reason in ("expired", "canceled", "overloaded_error"):
        fixes.append(request_for(original))              # unchanged; just re-run
    elif reason == "request_too_large":
        fixes.extend(request_for(c) for c in chunk(original))
    # refusal: not retryable — route it out of the pipeline

if fixes:
    client.messages.batches.create(requests=fixes)

Don’t re-run the whole batch. And refine the prompt on a small sample before committing a large volume, so you are not paying to discover a prompt bug across ten thousand documents.

One caveat on the shape of that repair, because it decides whether the pattern is safe. A resubmitted batch is a batch, and it carries its own 24-hour ceiling. So the code above belongs to a deadline with room for a second full window. Where the deadline is tighter, the same fixes list goes to client.messages.create one item at a time, on a path whose latency you can bound. The arithmetic below is how you tell which case you are in.

The rest of the lifecycle

Three of the six methods have not appeared yet, and each answers an operational question the create-poll-read path leaves open.

cancel stops a running batch. It does not stop it instantly, which is why processing_status has a "canceling" state between in_progress and ended. In-flight requests finish, the rest are abandoned, and the ones that never ran land in request_counts.canceled. MessageBatch.cancel_initiated_at records when you asked, and it is populated “only if cancellation was initiated”. So it doubles as the flag telling a later reader that this batch ended early on purpose rather than by expiry. Anything already succeeded at that moment is still in results() and still billable; cancelling is damage control, not a refund.

list enumerates your batches, which is the recovery path for the failure nobody plans for: the process holding batch.id died. A batch survives the client that created it, so the id is recoverable rather than lost. It is still worth writing the id somewhere durable at creation time, along with the custom_id map. A batch you cannot correlate back to inputs is a bill without a result.

delete removes the batch resource. Read that sentence narrowly, because the gap between it and “deletes the data” is where a compliance answer goes wrong.

It has a precondition the other methods don’t: “Message Batches can only be deleted once they’ve finished processing. If you’d like to delete an in-progress batch, you must first cancel it.” So the sequence for abandoning sensitive work is cancel, poll to ended, then delete — not delete.

What comes back is a two-field acknowledgement, {id, type: "message_batch_deleted"}. That object says the batch is gone from the API. It says nothing about how long the underlying request and response content persists in the provider’s logs, backups or abuse-monitoring systems — and nothing else in the API surface says it either. Those are governed by the provider’s data-retention and privacy policies, which live outside the SDK and change on their own schedule. Calling delete is application-level deletion. If someone asks whether the data is erased, the answer comes from the current retention policy and your commercial terms, not from this call.

Results have their own, separate lifetime. MessageBatch.archived_at is the “time at which the Message Batch was archived and its results became unavailable”, and results_url is populated only once processing ends. So results are a window, not a store. Collect them, persist them on your side, and then delete the batch to shrink the surface. Remember that the strongest control is the one applied before submission. Data you never sent needs no deletion story — which is why the custom_id above is opaque, and why an extraction pipeline should send the field it needs rather than the whole record.

SLA arithmetic falls out of the 24-hour ceiling, and it has to pay for everything downstream of the model, not just the run. Six terms, and most designs count only the third:

TermWhat it covers
Wsubmission lag — how long a document waits for the next batch to go out
Bthe batch itself, at most 24 hours and with no schedule inside that
Vvalidation of the results, including the stop_reason gate
Frepair of the failed fraction
Ddelivery: writing results where the consumer reads them
Rreserve, everything you did not think of

The deadline holds when W + B + V + F + D + R fits inside it. Call it W + 24 + …, since B is a ceiling and you do not get to assume the median.

Against a 30-hour deadline, submitting every 4 hours puts the worst-placed document at 28 hours before validation has even started, leaving 2 hours for V + F + D + R combined.

A second batch cannot fit inside those two hours: allowing the full window for each batch gives 4 + 24 + 24 = 52 hours against a 30-hour deadline. Use a synchronous repair path only if its capacity fits the remaining budget. R is the residue of a ceiling that does not move, so when the failed fraction outgrows it the fix is a shorter submission lag W, not a larger R. Failing that, revise the delivery commitment.

Two capacity limits sit underneath that arithmetic, and neither is visible in the SDK types. Look them up rather than inferring them. A batch has a maximum number of requests and a maximum total size. There are also account-level limits on how many batches can be in flight and on the tokens they consume. client.messages.batches.create takes an unbounded Iterable[Request], so Python will happily build a fifty-thousand-item list and let the rejection arrive from the server. Chunk large workloads into several batches by policy instead, and treat the chunk size as a number you looked up on the day — the same category as pricing.

The failure mode this prevents is worth naming, because it is the one that turns a capacity limit into a missed deadline. A batch rejected at submission has not started its 24-hour clock — it has consumed part of W and produced nothing. Resubmit it as three batches and you have spent the lag twice — once on the rejection, once on the retry. Validate the request count and the payload size before create, where the fix is free.

The reviewer should not be the author

Who should check the model’s output? The obvious answer, the session that produced it, is the weaker one, and the reason is specific rather than mystical. A generating session retains its own reasoning: the assumptions it made, the design it committed to, the alternative it rejected forty turns ago. Those are still in the context, still weighted, and still framing every judgement it makes about the result, so it already “knows” why the code is right and rationalizes past the flaw instead of catching it. A fresh instance can inspect the artifact without inheriting that argument.

Asking the generating session to review again, or enabling extended thinking, leaves that context in place. A separate review changes the supplied evidence rather than only the amount of effort.

Give the independent reviewer the artifact, criteria, and necessary constraints. This follows the review arrangement in chapter 8; its effectiveness remains something to evaluate on your task.

Be precise about what it buys, though, because “always use a fresh instance” is not quite the rule and shipping it as one causes its own failure. Independence is a stronger control for one specific risk: bias inherited from the generator’s own reasoning. It also costs you everything that reasoning contained. A reviewer with no context does not know which of two behaviors the requirements asked for. It does not know why the obvious approach was rejected, or that the odd-looking branch exists because of a bug in a dependency. Hand it only the diff and it will confidently report a defect that was a decision. That is a false positive, and chapter 9 already established what those do to the trust in everything else.

The fix is not to restore the generator’s context. It is to hand the reviewer a minimal, deliberate package in place of it:

  • the artifact under review;
  • the requirements or acceptance criteria it was built against;
  • the rubric, so severity means the same thing across passes;
  • the constraints that are not derivable from the code — the dependency bug, the compatibility promise, the deadline that justified the shortcut.

That package is small, it is written by you rather than inherited, and every item in it is a thing you would have wanted in a human code review anyway. What it deliberately excludes is the generator’s reasoning trace: the argument for why the code is right. Excluding that is the whole mechanism.

Self-critique still earns a place — just not that one. As a stage — “check your own work against this checklist before you hand it over” — it is cheap and catches the obvious. The claim it cannot support is being the only check, or being the check that decides whether something ships. Sequencing the two is usually better than choosing: self-critique first to clear the noise, then an independent pass on what survives.

Compare self-review, fresh review, and the two in sequence on a labelled set. That establishes which arrangement catches defects for your workload, including whether the extra pass just repeats earlier findings.

Multi-pass review for large work

A fresh reviewer solves the bias problem but not the scale problem. Ask one instance to review a 40-file change in one pass and its attention spreads thin: it misses local issues and produces contradictory findings across files. That is attention dilution, and the fix is multiple focused passes. For a large change, decide the scope of each pass. The design below separates issues visible within one file from issues that depend on callers or data crossing a module boundary.

The per-file pass sees one file and nothing else, so its prompt must forbid speculation about the rest of the change:

<criteria>{the severity rubric from chapter 9}</criteria>

<file path="billing/invoice.py">{the full file, with line numbers}</file>

Review ONLY the file in <file>. Report issues that are decidable from this
file alone: logic errors, unhandled inputs permitted by a signature,
resources not released, comments contradicting the code beneath them.

Do NOT report anything that depends on how other files call this one. If a
function looks wrong only under an assumption about a caller, say nothing.

Set reachability to "unknown" for anything you cannot decide from this file.
Return an empty findings list if there are none.

The cross-file pass never sees full file bodies, because if it did it would dilute exactly the way the single big pass does. It sees the interfaces and the diff of the boundaries:

<changed_signatures>{every added/changed/removed public signature, with file}</changed_signatures>
<call_sites>{grep of each changed symbol across the repo, with file and line}</call_sites>
<schema_changes>{migrations, serialized formats, API payloads}</schema_changes>

Review ONLY the interactions. Report:
  - a caller not updated for a changed signature or contract
  - data flowing between modules that changes type, unit, nullability or
    encoding along the way
  - a schema change without a corresponding read-path change
  - two modules that now both own the same invariant

Do NOT report issues internal to a single file; another pass owns those.

Say it in a schema, not in a delimiter

Use a structured finding format for both passes, because a pipe-delimited line fails in more ways than it looks. The pipe appears in real content all the time: in the Union[A, B] a signature review quotes, in a shell command inside a suggested fix, in a regex alternation. One of those in one finding shifts every field after it by a column, and nothing errors. Line numbers arrive as strings and need casting. A fix containing a newline ends the record early. And “output NONE if there are none” is a third grammar for the same response.

A Python union written as A | B, a shell pipeline, a regex alternation, or a Markdown table can put a pipe inside a finding. A multiline fix also breaks a line-based record. JSON string fields can represent those values without inventing another delimiter convention.

So define the finding as a schema and let the API enforce it:

class Finding(BaseModel):
    finding_id: str                 # stable within this pass
    path: str
    line_start: int
    line_end: int
    severity: Literal["critical", "high", "medium", "low"]
    reachability: Literal["untrusted-input", "valid-input",
                          "internal-only", "tests-only", "unknown"]
    blast_radius: Literal["one-request", "one-tenant",
                          "all-tenants", "stored-data"]
    issue: str                      # one line, free text, any characters
    evidence: str                   # the lines the finding is about
    suggested_fix: str
    root_cause: str                 # short phrase; the merge step clusters on it

class ReviewPass(BaseModel):
    pass_kind: Literal["per-file", "cross-file"]
    prompt_version: str
    findings: list[Finding]         # empty list means no findings

Every failure mode above disappears at once. issue and suggested_fix can contain pipes, newlines and shell commands, because they are JSON strings. line_start arrives as an integer. “No findings” is [] rather than a magic word. And the severity and reachability enums are genuinely enforced, since enum is one of the keywords that survives to the wire. A pass cannot invent a sixth severity level and quietly widen your rubric.

The finding_id, evidence and root_cause fields exist for the merge step, and they are the reason to design the record now rather than after the first merge goes wrong.

Each pass has a narrow, coherent scope the model can actually hold. Local correctness where the file is the unit; integration correctness where the data flow is the unit. It’s decomposition applied to review: complete coverage from focused pieces rather than diluted coverage from one overloaded pass. The two “do not report” clauses are what stop the passes from overlapping, and they matter more than they look. Without them you get the same finding twice, in two different vocabularies.

Merging what comes back

Forty per-file passes plus one cross-file pass produce a pile of findings, not a review. Two independent instances asked to review overlapping scopes will report the same defect in different words, and a reviewer who reads the same bug four times stops reading. Fold them deliberately:

  1. Normalize first. The schema above exists so that every finding arrives with a path, a line range, a severity and a root cause. Parse them into records before doing anything else. This is chapter 10’s argument arriving in a new place: a review pipeline that returns prose is a review pipeline you cannot merge.
  2. Cluster on root cause, not on proximity. Two findings on the same path within a small line window are candidates, and location is where the clustering starts rather than where it ends. Group by root_cause within the candidate set, and only fold together findings that make the same claim about the same underlying defect. Where the rule is ambiguous, ask a third instance to judge whether two issue statements describe the same defect. Give it the two statements and the two evidence spans, and let it answer “same,” “different,” or “related but not the same.” It is a cheap call, and it does not need the whole file.
  3. Keep provenance on everything you fold. A merged finding carries the finding_id and pass_kind of every finding that went into it. That is what lets a reviewer who disputes the merge unpick it, and what lets you measure later how often the clustering was wrong. A merge with no provenance is a lossy write.
  4. Do not union fixes across different diagnoses. This is the step that looks harmless and is not. Two findings can sit on the same three lines, agree on the severity, and still disagree about why the code is wrong. One says the lock is held too long; the other says it is the wrong lock. Concatenating their suggested fixes produces a remediation that is internally contradictory — and worse, one that reads as though a reviewer endorsed it. So: union the fixes only when the diagnoses matched, which after step 2 means only within a root_cause cluster. When the diagnoses conflict, keep both findings and mark them as conflicting. A developer reading two competing explanations of the same lines does the right thing. A developer reading one merged instruction that splices them together does not.
  5. Take the highest severity only within a cluster, and record the spread. Highest-severity-wins is a reasonable default for genuine duplicates and a bad one across a disputed merge. Where two passes in a cluster disagreed by more than one level, that disagreement is itself a finding about your rubric. Log it.
  6. Cross-file findings outrank per-file ones on the same lines. A per-file pass reporting “this argument may be null” and a cross-file pass reporting “the caller in orders.py now passes null” are the same defect. Only the second one tells a developer what to do. This is also the pass that can legitimately overwrite a per-file reachability of unknown with a real value, because it is the pass that can see the callers.
  7. Adjudicate rather than guess when a cluster is contested. Some clusters come out of step 2 as “related but not the same” and out of step 4 with conflicting diagnoses. Route those to a human, with both findings and both evidence spans intact. That is a handful of items out of forty files — and exactly the handful where an automatic merge would have produced confident nonsense.

Measuring the merge, and one metric people mislabel

Count what the merge folded and look at it weekly. Just be careful what you call it.

The duplicate rate is a redundancy measure, not a correctness measure. Two passes reporting the same defect means your scopes overlap — it says nothing at all about whether the defect is real. Both could be right, and both could be the same false positive reported twice — which, if anything, inflates a duplicate rate that you were reading as a quality signal. The two numbers to keep, with their denominators written down:

  • Duplication rate = (findings folded into a cluster) / (findings emitted). Denominator: everything the passes produced. What it diagnoses is overlapping scope, a missing “do not report” clause, or a cross-file pass that has started doing per-file work.
  • False-positive rate = (findings adjudicated not a defect) / (findings adjudicated). Denominator: only the findings somebody actually judged. This requires labels, which means a human or a trusted oracle looked at a sample and ruled on each one. There is no way to compute it from the findings alone, and any number presented as a false-positive rate without an adjudicated denominator is a different metric wearing its name.

Track both per category, because they diagnose different problems and the fixes point in opposite directions. A high duplication rate means tightening the scopes. A high false-positive rate in one rubric level tells you which level from chapter 9 has started poisoning trust in the rest. The remedy there is a sharper criterion, or temporarily disabling the category. Sampling for the adjudicated set is not free, so sample a fixed number per category per week rather than a percentage of a volume that moves. Chapter 14 does the stratified version properly.

What the split actually costs

Comparing the shapes on structure rather than on price, for a 40-file change:

ShapeCallsInput sentShared prefix
One big pass1all 40 files oncesent once
Per-file + cross-file41all 40 files once, plus interfaces againsent 41 times

Per-file review is not 40 times the input. Each file body is still sent exactly once; what repeats is the rubric and the instructions, and the cross-file pass re-sends only signatures and call sites. That repeated prefix is the identical, stable prefix prompt caching exists for. Which is why the rubric belongs in the system turn and the file body in the user turn. The extra cost of the split is 40 additional responses and 40 additional round trips, against one pass that reads everything and attends to none of it.

No dollar figures are given here because none were measured for this book. The structural comparison is the durable part; the multipliers move with pricing.

Confidence, and where it is allowed to be used

A supporting technique is to have each review pass self-report a confidence score alongside each finding, then use it to route reviewer attention. The line here is sharp, and it is easy to get backwards.

Raw, uncalibrated self-reported confidence is noise. A model has no inherent access to its own accuracy, which is exactly why chapter 9 rejected “only report high-confidence findings” as a prompt instruction. A number the model produces about itself is another generated token, not a measurement.

It becomes useful only through calibration against a labeled set. Score a sample of findings, check them by hand, and confirm that findings the model called 0.9 are right about ninety per cent of the time. Once that holds, the score is a legitimate routing signal for a batch of already-produced findings. Send the low-confidence ones to a human first, and spend the remaining reviewer capacity on a sample of the high-confidence ones. You have then allocated attention by risk rather than by arrival order. Chapter 14 is where calibration is done properly, including the stratified sampling that keeps it honest over time.

The boundary, stated once so it stays straight: confidence may route review of work already done; it may never trigger escalation on a live case. Chapter 13 rules it out there for a reason that does not go away with calibration. Escalation must fire on complexity — a policy gap, an inability to progress, an explicit request for a human. And a confident model handling a case it should never have handled is precisely the failure a confidence threshold cannot see. Batch review is forgiving because a misrouted finding is read later by someone; a live case is not.

Final thoughts

What to carry forward.

  • Three shapes: synchronous for blocking work, streaming when a person is watching, batch when nobody is.
  • Batch trades latency for price under a hard 24-hour ceiling. Results return out of order and correlate by custom_id; processing_status has three states; the result union is four-way and only succeeded and errored carry anything. It accepts tools but cannot execute them. Poll with a deadline, jitter and a cancel; delete removes the resource.
  • SLA arithmetic is W + B + V + F + D + R with B at the ceiling. Repairs run synchronously, and when the failed fraction outgrows R the fix is a shorter W.
  • An independent reviewer controls for shared-context bias; hand it a package. Per-file plus cross-file passes with explicit “do not report” boundaries beat one diluted pass. Return a schema, cluster on root cause, keep provenance, and leave conflicting diagnoses unmerged.
  • Duplication and false positives are different metrics with different denominators. Calibrated confidence routes a batch of review; it never triggers a live escalation.

For the overnight classification job, account for every submitted document and leave time to validate and repair the failed fraction. Keep the input mapping with the batch id; an answer that cannot be attached to its source is unusable even if the request succeeded.

For the pull-request review, inspect a disputed finding through its evidence and the passes that produced it. Preserve conflicting diagnoses until they can be resolved. The developer needs a defensible finding, not merely a shorter list.

Next: Arc 5 opens with context management — keeping the critical facts alive across long conversations and large codebases.

Comments