Skip to content

14 min read

A finished string is not yet a finished decision

On this page

The snapshots from the previous chapter arrive as a sequence of legal objects, each a little fuller than the last, and every one of them is a faithful reading of the bytes received so far. Faithful is not the same as safe to show. A snapshot can contain a value that is syntactically finished and semantically half-finished, in the precise sense that a field which has not arrived yet would change what it means, and a user reading the panel has no way to tell those apart because both look like a settled value in a box.

The unit of policy is the gate, and the rest of this topic keeps referring back to it: the withdrawal states of the next chapter open only for fields whose gates have opened, and the reserved layout slots two chapters on are sized for values whose gates are still closed.

Two rules, and only one of them is about syntax

The first rule is mechanical and comes straight out of the lexer. A value may be considered present once its own token has terminated: a string at its closing quote, an array at its closing bracket, an object at its closing brace, and a number, true, false or null at the delimiter that follows it. Anything before that is a prefix, and a prefix of a value is not a smaller value — "Refun" is not a shorter version of "refund", 12 is not a rounder version of 1200.

The second rule is semantic and is decided per field. A field is renderable only if it carries its meaning without the fields that have not arrived. Take a triage result whose completed form is this:

json
{
  "ticket_id": "T-40182",
  "recommended_action": "refund",
  "blocking_conditions": ["order_older_than_30_days", "prior_refund_within_90_days"],
  "confidence": 0.61
}

recommended_action fails the second test on its own: its meaning is conditioned by blocking_conditions, so on a stream where "refund" closes at 1.4 seconds and the object closes at 3.1, it is complete as a token 1.7 seconds before it is complete as a decision. ticket_id passes trivially, because an identifier means the same thing whatever follows. Both rules are needed and neither implies the other: passing the first and failing the second is a refund button that lights up 1.7 seconds early, and passing the second and failing the first is the 12-instead-of-1200 case from the previous chapter, where the field is semantically self-contained and syntactically unfinished.

There is a structural lever worth knowing before you start writing gates, because it can remove some of them. With schema-constrained generation the model emits keys in the order the schema declares them, so the order of properties in your JSON Schema is a decision about what your UI can safely show early. Declaring blocking_conditions before recommended_action means that by the time the action's closing quote arrives, its qualifier is already in hand, and the gate collapses into the syntactic rule. This costs nothing and it does not remove the need to think field by field — reordering helps only where the dependency is between two fields of the same object, and it can fight with the generation quality you get from letting the model reason toward a conclusion rather than commit to conditions first.

The property that lever rests on is worth stating rather than assuming, since a gate built on it inherits its conditions. A constrained decoder may only emit tokens that keep the document acceptable to a grammar derived from the schema, and where that grammar is derived in declaration order, the only key it will accept after the opening brace is the first declared property, the only one it will accept after that value is the second, and so on down the list. Field order in the schema is therefore field order on the wire. Two conditions ride on that and both are worth checking before anything depends on them: the generation has to be genuinely constrained rather than merely prompted, because an unconstrained model treats a schema as a strong suggestion and follows it usually rather than always, and the grammar has to be one that fixes property order rather than accepting any permutation of the keys. Where either fails, declaration order is a tendency you can measure and not a guarantee you can gate on, and the honest version of the reordering trick is then to keep the semantic gate as well.

A policy for four fields, defended one at a time

Take a triage summary with summary (a prose paragraph), urgency (an enum of routine, soon, immediate), red_flags (an array of strings) and disclaimer (a fixed legal sentence). Product wants all four on screen as early as possible. The honest answer is four different policies.

FieldSyntactically readyRender policyWhy
summarygrows continuouslystream, as plain text, trimmed to the last complete markdown blockprose is monotonic in meaning; markdown is not
urgencyat its closing quotehold until red_flags closesa legal, readable value that a later field can contradict
red_flagsper element, at each closing quotestream elements; suppress the empty state until ]each item stands alone; absence does not
disclaimerat its closing quotedo not stream, and preferably do not generatea partial legal sentence is a legal statement you did not write

summary is the field streaming was invented for, and it is close to safe because prose is monotonic in meaning, in that each new sentence adds to what came before rather than revising it — a reader who sees the first sentence of a paragraph and then the second has not been misled by the first. The hazard is markup rather than meaning. A half-written link renders as the literal text [see the refund policy](https://help.exa for 400 ms and then reflows into a link, and a half-written fenced code block turns the rest of the panel into code until its closing fence arrives. The remedy is to render only up to the last complete block-level construct and buffer the tail, which costs a few hundred milliseconds of lag on the newest words and removes the entire class.

urgency is where the refund case lives, transposed. routine is a complete string, a member of the enum, and it can appear two seconds before a red_flags entry reading "patient reports chest pain". The default policy of showing it immediately is what produces the incident, because a colour-coded badge saying Routine is not a neutral display of a partial result — it is an instruction, and people act on it. Holding it until red_flags closes costs the user a second or two on a field that is one word long, which is close to no benefit foregone. If product insists on something in that space, the thing to put there is a pending state, not the value.

The belief that produces the default is that showing a value early is a neutral act, on the grounds that the value is true: the model did emit routine, the parser did read it correctly, nothing misbehaved, and the field will be updated if anything changes. Every link in that chain holds and the conclusion still fails, because a rendered field is not a report of what the model said. It is the interface's claim about the world, made in the same typography the product uses for settled facts, and a reader has no way to separate this is the answer from this is the answer so far unless the interface separates them. The question to ask of a field is therefore not whether its value is true but what somebody will do in the next second having read it. For a prose summary the answer is keep reading, which is why streaming it is safe. For a colour-coded triage badge the answer is move to the next case, which is why it is not.

red_flags splits into two claims that behave differently. Each element that has closed its quote is a real red flag and can be shown, because adding a second flag does not change the meaning of the first. The array's emptiness is not a claim at all until the closing bracket arrives, so rendering "No red flags found" against an array that currently has zero elements is asserting something the model has not said. Show flags as they come, and show nothing at all in the empty case until ].

That asymmetry is worth stating as a rule, because it recurs wherever a container is streamed. An element that has closed is a finished statement: the model wrote patient reports chest pain, and finishing the array will not unwrite it. Whereas the array's length, its emptiness and any total computed across it are statements about a set that does not exist yet, and the bracket is the event that brings the set into being. Elements and container therefore carry different evidence at the same instant, and a renderer that treats them alike is guaranteed to be wrong about one of them. The empty-state case is the sharper of the two because it inverts the claim rather than weakening it: the bytes so far say nothing whatever about red flags, and the panel says there are none. A default has manufactured a finding out of information that has not arrived, which is the same defect a nullable database column commits when it lets an absent field stand in for a measured zero — here it arrives through a component rather than through a schema.

disclaimer is the field to refuse. A fixed legal sentence contains no information the model is generating, so streaming it means displaying half of a legal notice — "This assessment is not a substitute for" — as a standalone sentence to a user who may screenshot it. That field should not come from the model at all; put it in the client as a constant and render it before the stream starts. Where compliance requires the model to produce it, render it on close and never before.

What the gates actually cost

The objection to all of this is that gating gives back the latency streaming was bought for, and it is answerable with the arrival times of a real stream rather than in principle. On the triage object above, generated at roughly 90 output tokens per second, summary starts arriving at 280 ms and finishes at 2.2 s, urgency closes at 2.4 s, red_flags closes at 2.9 s, and the object closes at 3.1 s. Under the ungated policy the user sees their first pixel at 280 ms. Under the policy in the table they still see their first pixel at 280 ms, because summary is the field that arrives first and it is the field that streams; urgency appears 500 ms later than it would have, and the disclaimer appears immediately because it was never in the stream to begin with.

That 500 ms is one subtraction, and it is the only number in the trade. urgency closes at 2.4 s, its gate opens when red_flags closes at 2.9 s, and the field waits the difference. Nothing else in the comparison moves: the first pixel is summary's opening words at 280 ms under both policies, because summary streams either way, and the disclaimer arrives before the stream does because it is a constant held in the client. So the entire price of the policy in that table is half a second on a field one word long, paid on a panel that was going to be in motion for three seconds regardless. The version of this argument that loses is the one conducted in principle, where "gating gives back the latency" and "gating costs almost nothing" are equally assertable.

The reason the cost is that small is that generation order and gate order mostly agree. The fields worth streaming are the long ones, long fields arrive first in wall-clock terms because they take the longest to write, and the short qualified fields that need gates are cheap to wait for precisely because they are short. Where that stops holding — a schema whose first property is a one-word verdict qualified by a paragraph three fields later — the fix is the schema ordering lever above, not a looser gate. The engineering write-up Streaming AI responses and the incomplete JSON problem walks the same arithmetic from a product angle and lands in the same place.

Measure it on your own object before arguing about it, because the number that matters is time-to-first-useful-pixel and not time-to-first-token. A stream that puts a spinner-equivalent on screen at 280 ms and the first readable sentence at 900 ms has a different user experience from one where those are the same moment, and only the second number moves when you add gates.

Expressing the policy where it can be reviewed

Scattering these decisions through a component tree makes them impossible to audit, and the field that gets it wrong will be the one added six months later by someone who never read this chapter. A declarative map keeps them in one file:

ts
type Gate =
  | { kind: "stream" }                          // safe to show as it grows
  | { kind: "onClose" }                         // show when its own token ends
  | { kind: "after"; fields: string[] }         // show when these have closed too
  | { kind: "never" };                          // not from the stream, ever

const triagePolicy: Record<string, Gate> = {
  summary:     { kind: "stream" },
  urgency:     { kind: "after", fields: ["red_flags"] },
  red_flags:   { kind: "onClose" },             // per element; see arrayElements below
  disclaimer:  { kind: "never" },
};

function visible(snapshot: Partial<Triage>, closed: Set<string>, field: string) {
  const gate = triagePolicy[field];
  switch (gate.kind) {
    case "never":  return false;
    case "stream": return field in snapshot;
    case "onClose": return closed.has(field);
    case "after":  return closed.has(field) && gate.fields.every((f) => closed.has(f));
  }
}

The closed set is the piece your parser owes the renderer, and the lexer of the previous chapter already knows it: a field is closed when the value that follows its key has terminated, which the snapshot logic detects as it repairs. Handing the UI only a Partial<Triage> object is the common shortcut and it throws away exactly the information the second rule needs, because a partial object cannot distinguish summary that has finished from summary that is still growing. Emit { snapshot, closed } together.

Arrays want one extra rule: elements are gated individually, and the container's completion gates only the statements you make about the container — its length, its emptiness, any aggregate you compute from it. A "3 red flags" counter is an aggregate and belongs behind the closing bracket with everything else.

Where a render policy stops

None of this is schema validation. A gated field can be a well-formed string of the right enum type and still be wrong, and the check that the finished object conforms to the schema is a separate pass that runs on the complete document — the one topic 5.2, on constraining and validating model output, established. Render policy answers when, and validation answers whether.

It is also not a policy for actions. Everything above concerns pixels, where the cost of showing something a moment early is a moment of confusion. An automated consumer that reads recommended_action from a snapshot and calls the refund API has done something that cannot be reflowed, and the correct gate for any field that triggers an irreversible effect is the close of the whole object plus validation, with no exceptions for fields that look self-contained.

The honest limitation is that a strict reading of the second rule can gate away most of the benefit. In a schema where every field qualifies every other — a compliance verdict, a medical triage, a pricing decision — the correct policy is that nothing renders until the object closes, and at that point you have re-derived the non-streaming response with extra machinery. That is a real outcome and it is worth reaching deliberately rather than discovering later, which the last chapter of this topic takes up as a design question rather than a rendering one.

Two conditions, then, and the second is the one your tooling will not supply. A parser can tell you that a token terminated; only the schema's author can tell you that a terminated token means what it appears to mean without the fields still in flight. Writing that down per field, in one reviewable place, is cheap in the case where generation order and gate order agree and is the whole of the work in the case where they do not.

Everything so far has assumed the object eventually closes. When it does not, the gates never open, the parser holds six fields of nine, and the question stops being what to show and becomes what any of it was worth.

Streaming structured output — read the current version at https://daviddominguez.dev/learn/en/streaming-structured-output/a-finished-string-is-not-yet-a-finished-decision

Back to top