Skip to content

16 min read

Content blocks and structural validation

On this page

A conversation is stored so that it can be sent back, and what gets sent back is not a transcript but a list of typed blocks per message. A schema built out of a message table and a child table of ordered parts gives you typed columns to put those blocks in; what it does not yet give you is a specification of which values it is allowed to touch. Trailing whitespace stripped from a text block is harmless. The same treatment applied to a thinking block — the working the model produced before answering, returned as its own block rather than folded into the reply — makes the request unsendable, and nothing in a column type says so.

The errors an API returns before generating anything

A replay harness was pointed at 5,000 stored conversations to check a model upgrade, and 1,412 of them came back as HTTP 400 before a single token was generated. The errors clustered into four strings:

text
 812  messages.7: `tool_result` block(s) provided when previous message
      does not contain any `tool_use` blocks
 391  messages.4.content.0.thinking.signature: Invalid signature
 148  messages.2.content.1.text: text content blocks must be non-empty
  61  messages.9: `tool_use` ids must be unique within a request

Two things in that listing are worth extracting before reading on. Add the four counts together — eight hundred and twelve, three hundred and ninety-one, one hundred and forty-eight, sixty-one — and they come to 1,412, which is the whole of the rejected set rather than the head of a longer tail. Four causes account for every failure, and a bit over a quarter of the 5,000 conversations hit one of them. That exhaustiveness is what makes an inventory of block kinds a finishable piece of work: the failure modes are enumerable because the rules are.

The second thing is the shape of the error strings. messages.7 and messages.4.content.0.thinking.signature are paths into the parsed request rather than prose descriptions of what went wrong. Counting from zero, as the content.0 in the second string shows it must, the first names the eighth message in the body and the second names the signature field of the first block of the fifth message's content. Those coordinates map onto your own storage directly — a message row and an ordinal — which is why a rejection tells you which row to go and read rather than which conversation to bisect.

Not one of those errors is about whether the conversation makes sense. The API never asked whether the shipping estimate was plausible, whether the tool arguments matched the schema the tool advertises, or whether the assistant's answer followed from the result. It checked presence, pairing, ordering and integrity, and it rejected on those alone. That is worth dwelling on, because a rule you can state as "block of type X must be followed by block of type Y, unmodified" is a rule a table definition can enforce, whereas a rule about meaning is not.

What a single tool-using turn actually contains

Here is one assistant turn from a conversation with extended thinking enabled — the provider mode in which the model works through the problem in writing before answering, and returns that working as its own content block — serialised the way the provider returns it and the way you must send it back:

json
{
  "role": "assistant",
  "content": [
    {"type": "thinking",
     "thinking": "The customer is asking about TR-4410. I should check\nthe live estimate rather than quoting the catalogue figure.",
     "signature": "ErUBCkYIBRgCIkAwZ2h1cHJvb2Y5MnhLd2VyVHkxMjM0NTY3ODkw..."},
    {"type": "text",
     "text": "I'll check that for you."},
    {"type": "tool_use",
     "id": "toolu_01A7bK9wq2Vd8sJ1n4Yh2P",
     "name": "shipping_estimate",
     "input": {"sku": "TR-4410", "zip": "94110"}}
  ]
}

And the turn that answers it, which the user role owns even though no human wrote it:

json
{
  "role": "user",
  "content": [
    {"type": "tool_result",
     "tool_use_id": "toolu_01A7bK9wq2Vd8sJ1n4Yh2P",
     "is_error": false,
     "content": "{\"days\": 2, \"carrier\": \"UPS\"}"},
    {"type": "text",
     "text": "That's for the black one, right?"}
  ]
}

Five block kinds appear across those two turns, and each has a different relationship to your storage. The text block is the only one that is genuinely just a string. tool_use carries an identifier the provider minted and an input object the model produced. tool_result carries the identifier back, plus an error flag that is part of the conversation's meaning — the model behaves differently after a failed call, and a replay that silently converts is_error: true into a success is replaying a different conversation. thinking carries prose plus a signature, which is a cryptographic attestation that the provider produced that exact text. Image and document blocks, absent here, carry a reference to bytes that live somewhere else entirely.

The signature deserves a paragraph of its own because it is the one block kind that behaves unlike anything else in a normal message store. It is not a hash you can recompute, and it is not something you can generate. It is issued by the provider over the thinking text, and on the way back in, the provider verifies it. Change a newline, strip trailing whitespace, run the text through a Unicode normaliser, re-wrap it at 80 columns for a debug view and store the wrapped version — any of those and the verification fails with the error in the second row of the counts above.

The difference between a hash and a signature is the part to have straight, because it decides what your options are when a block has been damaged. A hash is computed from the text alone, so anyone holding the text can compute one, check it, and produce a fresh one after an edit. A signature is computed from the text together with a key the provider holds and does not publish. Producing one therefore requires the key, and you do not have it, which leaves verifying as something the provider can do and re-signing as something nobody outside it can. The only valid signature for a stored thinking block is the exact string of bytes that arrived carrying it. Verification is an equality test over those bytes rather than a comparison of meaning, so one collapsed space fails it as completely as a rewritten sentence would.

The reason a signature exists at all explains why the rule is absolute rather than a warning. Thinking text is content the model conditions on, and it is content the model wrote about its own plan, so an endpoint that accepted arbitrary thinking blocks from a client would be accepting instructions dressed as the model's own reasoning. Signing it lets the provider distinguish "this is reasoning I produced" from "this is a string someone typed", which is the same argument that makes a session cookie signed rather than plain. It also means the signature is scoped to the exact bytes and cannot survive any transformation, however innocuous — there is no canonical form, no whitespace-insensitive comparison, and no way to re-sign on your side.

The cookie comparison holds for the reason and breaks on the remedy, which is where the comparison stops being safe to lean on. A signed session cookie is signed with a key your own server holds, so a cookie whose contents have to change is re-signed on the way out and nothing is lost by the change. Here the key sits with the provider. A thinking block that has been through any transformation cannot be repaired at all — only replaced by the original bytes if you still have them, or dropped if you do not. Storage that tidies values on the way in is a harmless habit in the first case and a data-loss path in the second.

The rules are all about shape

The constraints the API applies to a block list are small enough to enumerate, and enumerating them is how you decide what your schema must guarantee rather than merely permit.

Blocks within a message are ordered, and the order is meaningful: when extended thinking is on, the thinking block comes first in the assistant turn, before any text or tool call. Every tool_use in an assistant message must be answered by a tool_result with a matching tool_use_id in the very next message, and every tool_result must refer to a tool_use in the message immediately before it. Identifiers must be unique within a request. Text blocks cannot be empty, which sounds trivial until a UI that renders an empty paragraph before a tool call starts persisting it. And thinking blocks must arrive byte-identical to how they left, including the signature and including the redacted_thinking variant, whose payload is an opaque encrypted blob you cannot read at all and must nonetheless store and return.

Every one of those is checkable at write time. (message_id, ordinal) as a primary key gives you ordering that cannot degrade. A check constraint gives you non-empty text. A unique index on tool call identifiers gives you the uniqueness rule. Pairing needs application-level enforcement or a split table, since the identifier appears twice by design. Byte-for-byte integrity of thinking is the only one that no constraint can enforce, because the database cannot tell a faithful string from a normalised one — it has to be enforced by the write path never touching the value, which is a code review problem and a very easy one to lose.

The line between the four enforceable rules and the last one is worth naming, since it is the property that decides where each rule has to live. A constraint can see the row being written and the rows already committed, and nothing besides. Presence, uniqueness, ordering and pairing are all statements about exactly that material, so each can be written down once and then checked by the database on every path that writes. Byte-level integrity is a statement about a value that no longer exists anywhere the database can reach, namely the string as it arrived on the wire, and a column holding a tidied string is indistinguishable from a column holding a faithful one. The only available enforcement is therefore upstream, in the code that decides what to hand the database in the first place.

The redacted_thinking variant makes plain what storage is being asked for here. The provider needs the reasoning back in order to carry the turn on, and it has decided you are not to read it, so what it hands you is a blob only it can open. The obligation that comes with it is to keep the bytes and return them in the right position, and that is the whole contract — there is nothing in it to validate, normalise or render. A store designed on the principle that every column should mean something to the application has no natural home for a value like that, and a value nobody can read is the first thing a tidy-up deletes.

Which blocks must come back unchanged

Sorting the block kinds by how much freedom you have is the useful output of this inventory, because it tells you where to spend care. One term in the table needs unpacking first: a prompt cache is the discount a provider gives on a request whose leading blocks are byte-identical to ones it has processed recently, so a stored block you tidied up on the way out is charged at full rate even when the tidying was otherwise harmless.

block kindmust round-trip verbatimsafe to normaliseconsequence of dropping it
textnotrailing whitespace, if you accept a prompt-cache missthe message loses its content
tool_use.idyesnothingresult cannot be paired; request rejected
tool_use.inputyeskey order onlythe replay runs a different call than the original
tool_result.contentin effect yesserialisation whitespacemodel reacts to an observation it never saw
tool_result.is_erroryesnothinga failure replays as a success
thinking.thinkingyes, byte-for-bytenothing at allsignature check fails, or the plan is lost
thinking.signatureyes, opaquenothingrequest rejected on the next tool turn
redacted_thinking.datayes, opaquenothingsame, and you cannot inspect it to debug
image / document referencethe bytes must resolvethe reference itself may be re-mintedattachment unresolvable at replay

Two rows in that table have a subtlety that is easy to skim past. tool_use.input must round-trip because it records what the model asked for, not what your tool did — if the model produced {"sku": "TR-4410", "zip": "94110", "expedited": null} and your handler ignored the third key, storing the two keys you used loses the evidence that the model emitted a third. That evidence is the whole content of a class of bug reports about tools being called with arguments nobody expected. And tool_result.content is marked "in effect yes" rather than a flat yes because the provider does not verify it, so nothing rejects a reformatted result; the reason to keep it exact is that the model's next token is conditioned on those bytes, and a result you re-serialised with different key ordering is a different prompt with a different prompt-cache prefix.

The expedited key in that first case disappears for a mechanical reason rather than a careless one. What a handler writes down is its own view of the arguments, and its view is the part of the object its code reads. A field nobody wrote a line for leaves no trace of ever having been present, since there is no step at which an unread key is noticed and recorded. Storing the object before any of your code touches it is the only arrangement in which the third key survives, because every later step is a projection onto whatever that step happened to care about. That is the argument for keeping the provider's raw block alongside the typed columns rather than treating the typed columns as the record.

The right-hand column is the one to design against. Three of those failure modes are loud — the request is rejected and you find out in a second. The rest are quiet: a fabricated tool result, a lost error flag, a discarded thinking block on a provider that tolerates the omission. Those produce a request that is accepted and a conversation that is subtly not the one you stored, which is the worse outcome, because a replay harness reports it as a pass.

Against that, the list of things you can drop without consequence for replay is shorter than teams expect but not empty. Token usage counters, stop_reason, the provider's response id, the streaming event boundaries, and the rendered HTML your frontend produced are all derived or observational. Keep the usage counters and the response id anyway — they are what a support ticket and a cost query are joined on — but keep them on the message row as metadata, not as parts, because they are facts about the exchange rather than content the model consumes. The distinction is easy to state and easy to get wrong under pressure: if it goes back into the next request, it is a part; if it only ever goes into a dashboard, it is metadata.

Validity, fidelity and portability

Passing the validator says nothing about whether you reconstructed the original conversation. You can hand the API a tool_result you invented, containing a shipping estimate that never came from any tool, paired with a real tool_use identifier, and it will be accepted and the model will act on it. That is a feature — it is how you test how the model handles a hostile or malformed tool response — but it means a green replay is evidence of a well-formed request and nothing more. Topic 2.3 made the same point about assembly: what you can rebuild is a plausible input unless you stored the real one. The block inventory narrows the gap considerably, since it removes the categories where you were previously guessing, and it does not close it.

Structural validity and fidelity get confused for a reason built into the feedback loop rather than out of carelessness. A 400 is the only automated judgement the provider ever passes on a stored conversation, so it becomes the test, and a test that passes gets read as the property somebody wanted holding. What the validator checks is presence, pairing, ordering and integrity. What you wanted is that these bytes are the bytes that ran. The first is a strict subset of the second, and nothing in a request body carries provenance, so no response the API can send is capable of reporting on it. Provenance has to be established on your side, by storing the original and comparing the assembled body against it.

The inventory is also not a portable format. These block kinds and these rules belong to one provider's API; another provider names the same concepts differently, puts tool results in a tool role rather than a user role, and has no concept of a signed thinking block at all. Storing a normalised, provider-neutral shape is a reasonable instinct and it is where signatures go to die, because the lowest common denominator across providers has no field to put a signature in.

Normalising on the way in is a projection, and what matters about a projection is when it is applied. A field with no counterpart in the neutral shape has nowhere to be written, so it is dropped at insert time, quietly, on every row, and the loss surfaces months later when a replay needs a signature that was never stored. The same projection applied on the way out is a different proposition: the provider's block is still in the row, so a reader that needs the original dialect can have it and a reader that wants a neutral view can compute one on demand. The workable arrangement is to store the blocks in your own typed columns for querying and keep the provider's raw block in a payload column for replay, along with a column recording which provider and API version produced it, so a future reader knows which dialect it is looking at.

An inventory is worth doing once, on paper, before the tables are written, because it converts a vague obligation to "store the message" into a list of per-field commitments that a schema and a code review can hold. Presence, pairing, ordering and uniqueness become constraints. Byte-level integrity becomes a rule that the write path touches nothing, enforced by nobody but the people reading the diff. And the blocks that are safe to drop become an explicit list, which is how anyone tells a value that was discarded deliberately from one that fell out of a serialiser.

Two of the verbatim categories now need more than a promise not to touch them. Attachments are a reference to bytes that live outside your database and, quite often, outside your control, so "the bytes must resolve" is a claim about someone else's retention policy. Thinking blocks are the block kind that write paths discard first, because they are large, they are not shown to the user, and nothing appears to break for weeks.

Conversation persistence schema design — read the current version at https://daviddominguez.dev/learn/en/conversation-persistence-schema-design/content-blocks-and-structural-validation

Back to top