← Writing

Which fields are material? What makes a digest mean something

2026-08-14 authorization · security


A tool is described by a JSON schema: its parameters, their types, what it accepts. This vocabulary fits an interface. A security boundary needs a different one, and the reason is plain.

A schema says this tool accepts a recipient, an amount, a currency, a destination account. It cannot say this tool may transfer fifty thousand dollars to an arbitrary recipient without further approval, because it has no idea what the security policy is. It describes what is possible, not what is dangerous.

A team that reads its authorization out of the schema will have a bad day. The agent sees what the tool accepts, calls it, and the call executes. The fact that those particular parameters were dangerous was never in the schema, because the schema was never asked.

The classification that everything else depends on

A schema contains many fields. Most are informational. Some are material, meaning changing them changes what the action is. A recipient is material. An amount is material. A destination is material. A trace identifier is context.

That classification is what determines what a digest covers, and therefore what an approval is bound to. Get it wrong in one direction and approvals silently fail to protect: the agent asks for a $100 refund, gets approved, then changes the amount, and the digest still matches because the amount was never in it.

Get it wrong in the other direction and every approval is refused: the agent retries with a new trace ID, the digest changed, and the operator is asked to approve an action they already approved. Both failures are silent, and the second one is usually diagnosed as “the hash is broken”.

   every field the tool declares
         │
         ▼
   ┌──────────────────────────┐
   │ has somebody reviewed    │
   │ this as context?         │
   └─────┬─────────────────┬──┘
        yes               no
         ▼                 ▼
  informational      MATERIAL
  (hash may skip)    (the default)

One question settles the awkward cases:

What is the smallest set of fields where changing any one of them would mean the operator approved a different action?

That framing produces the right answer on the awkward fields. notes is free text attached to a refund. If the operator approved a refund of $100 to customer A, and the agent changes the internal note, the operator approved a different action in no meaningful sense, so the note is informational. A field named refund_reason_code is a different story: the policy routes differently on the reason, so it is material even though it reads like metadata.

The last column is the part that decides whether your approvals hold.

FieldMaterial?WhyIf it is left out of the hash
recipientMaterialThe operator approved a payment to a specific partyAn approval for one recipient carries over to every other recipient
amountMaterialIt is the size of the actionThe agent asks for $100, gets approved, changes the amount, and the digest still matches
destination, destination_accountMaterialIt names where the money landsAn approved transfer quietly becomes a redirect
direction, currencyMaterialThey decide which side movesTwo opposite actions share one approval
refund_reason_codeMaterialPolicy routes differently on the reason, though it reads as metadataA route that skips review inherits the approval meant for the normal route
amount_hint plus amount_scaleMaterial, split across two fieldsThe pair reconstructs the amount and neither field is the amountThe amount changes and the digest still matches
notesInformationalFree text on a refund, with no bearing on where the money goesCorrect as is. Including it makes approvals churn on every retry
trace_id, request_id, model, localeInformationalContext about the call, not the actionLive approvals get refused on every retry that carries a fresh trace ID
tags, labels, metadata, flagsInformationalThe same facts in a different orderThe canonicaliser ends up deciding their order matters, a schema question nobody answered
redirect_toMaterial until reviewedA vendor-added field nobody has classifiedA denylist of known-harmless fields stops covering it, with no signal anywhere
Anything the tool has not declaredMaterial until reviewedA schema-blind tool that accepts arbitrary JSON cannot be classifiedThis is why the default has to be material

Two designs, and why the second one wins on a closed envelope

Once you have the classification, there are two ways to build on it, and the choice matters more than the classification does.

Design one: classify, then allowlist the informational fields.

INFORMATIONAL = frozenset({"trace_id", "request_id", "model", "locale"})
material = {k: v for k, v in payload.items() if k not in INFORMATIONAL}

This is the obvious design and it has one virtue: approvals survive incidental churn. It also has a permanent cost. Every entry is a standing claim that a field does not affect the action, and it is a claim that somebody has to re-verify every time the tool’s semantics move. The next tool version adds redirect_to and the digest stops covering it with no signal anywhere, because a denylist of known-harmless fields only fails in the direction nobody is watching.

Design two: bind the whole envelope.

If the thing you are hashing has a small, closed, reviewed shape, a type instead of a bag, then there is no classification to get wrong. Every field is material because the envelope is the action. Nothing can be added to it without being a type change somebody compiles, and nothing can fall out of the hash without changing that type.

Design one: allowlist the informational fieldsDesign two: bind the whole envelope
What you maintainA list of fields that do not affect the actionThe shape of a closed type
When a vendor adds redirect_toThe field silently leaves the hash, and no signal appears anywhereAdding it is a type change somebody compiles, and the new shape needs its own approval
Incidental churnApprovals survive itApprovals break, and an envelope carrying incidental fields was a design error you could have caught immediately
Where the cost landsA standing claim somebody re-verifies every time the tool’s semantics moveNothing goes stale, because there is no list to go stale
Where it fitsAn argument bag you do not controlAn envelope you do control

The usual objection to design two is that an incidental field change invalidates a live approval. That is a real objection, but it is smaller than it looks: it bites only if the envelope actually carries incidental fields. A purpose-built envelope, the one whose fields are the decision-relevant facts and nothing else, does not have them, because adding a trace ID to a security envelope is a design error you catch immediately.

So the rule that falls out is: the narrower the envelope, the more of it you can hash unconditionally. A free-form argument bag needs a classification to be defensible. A closed typed envelope does not, and for a closed typed envelope the unconditional hash is both simpler and stronger, because there is no list in the system that can go stale.

This matters more for MCP than for a traditional API, because a schema is close to the only artifact the protocol surfaces. A traditional API has a documentation page, a security review and an OAuth scope list. MCP has a name, a schema and a capability list. If you are not deciding what counts as material, nobody decided for you.

Canonicalisation is a policy decision

Equivalent JSON representations must produce the same identity, and different material values must not. Key ordering is the easy case; the rest is where this actually bites.

def canonical_value(v):
    if isinstance(v, bool):        # bool before int, because bool IS a subclass of int
        return {"__bool__": v}
    if isinstance(v, int):
        return {"__int__": str(v)}
    if isinstance(v, float):
        # integral floats collapse onto the integer form
        return {"__int__": str(int(v))} if v.is_integer() else {"__float__": repr(v)}
    if isinstance(v, str):
        return unicodedata.normalize("NFC", v)
    if isinstance(v, list):
        return [canonical_value(x) for x in v]
    if isinstance(v, dict):
        return {k: canonical_value(v[k]) for k in sorted(v)}
    if v is None:
        return {"__null__": True}
    raise Uncanonicalizable(repr(v))

The pipeline that turns a payload into an identity runs like this:

   {"amount": 100.0, "ok": true}
   │
   ▼
   canonical_value() on every node
     bool  → {"__bool__": True}   before int
     100.0 → {"__int__": "100"}   collapses onto int
     "é"   → NFC
   │
   ▼
   object keys sorted recursively
   arrays left in their own order
   │
   ▼
   SHA-256( domain || canonical )
   │
   ▼
   action_digest, compared against
   the digest on the stored approval

The tag wrappers are deliberate. Without them, the string "true" and the boolean True could canonicalise to the same thing, and a payload that says {"verbose": "true"} would authorise identically to one that says {"verbose": true}, which matters the moment a downstream tool coerces one to the other.

Numbers. 100, 100.0 and 1e2 are the same amount of money and must produce the same digest, or every retry that goes through a JSON round trip gets a spurious refusal. There are two defensible answers and both are in production use. Collapse the integral floats onto the integer form, as above. Or refuse them: reject the value at validation with a typed error, so that the caller has to send an integer or a string and the ambiguity never reaches the hasher. The second is the stricter option and it has an advantage that is easy to miss. Refusing a value cannot lose information, whereas collapsing one silently asserts that two different wire representations meant the same thing.

For money specifically the cleanest resolution is to take it off the float table entirely: represent an amount as an integer in minor units. 50000 is unambiguous, has no precision ceiling a JavaScript client can trip over, and compares directly against a threshold expressed in the same unit. A digest layer that accepts a float is carrying a precision problem it does not need to have.

Unicode. NFC normalisation exists because é can arrive as one code point (U+00E9) or as e plus a combining accent (U+0065 U+0301). They render identically, they hash differently, and if the downstream mail system treats them as the same address then your digest will refuse an action that would have gone to the same place.

Order sensitivity is a property of the schema

This is the one that gets decided by accident.

ORDER_INSENSITIVE = {"tags", "labels", "metadata", "flags"}

A list of tags in a different order is the same action. A list of operations in a different order might not be: “transfer A then B” and “transfer B then A” can differ if the second transfer depends on the first one’s result. A list of recipients is order-insensitive for authorisation while being order-sensitive for delivery, which is exactly the kind of thing nobody thinks about until the canonicaliser and the mailer disagree.

Global sorting is the wrong default. The cheap correct rule is narrower than a config list: sort object keys, because an object’s key order is carrying no meaning, and leave arrays alone, because whether an array’s order is meaningful is a property of what the array means, and only the schema knows that. A canonicaliser that sorts arrays to be helpful has silently decided that every list your product has is a set.

What to actually check

Build an equivalence corpus. It is the only way to catch a canonicalisation bug, because the bug’s signature is two payloads that should be identical and are not, and eyeballing payloads does not find it.

PairExpectedWhat to assert
Keys in two different orders, at every nesting depthMust hash the sameCanonical json equality, so a failure points at the sorter
{"amount": 100} re-serialised by a different writerMust hash the sameCanonical json equality
é as precomposed and as decomposedMust hash the sameCanonical json equality after NFC
{"amount": 100} and {"amount": 101}Must not hash the sameDifferent digest
{"recipient": "a"} and {"recipient": "b"}Must not hash the sameDifferent digest
{"recipient": "a@b"} and {"recipient": "ab@"}Must not hash the sameDifferent digest
{"verbose": true} and {"verbose": "true"}Must not hash the sameDifferent digest
{"amount": 100} and {"amount": 100.0}, if the design rejects floatsMust not reach the hasherA typed validation error, not a digest comparison

That last row earns its place. A test that asserts “these two hash the same” is testing the wrong layer, and it keeps passing after you change the policy underneath it.

Then run property-based tests over generated payloads, asserting that any two payloads differing only in key order hash identically. Write the corpus before you need it. A canonicaliser that has only been tested on hand-picked examples has been tested on five examples.

Limitations worth stating

The classification is a human judgement, and any code that consumes it only enforces the outcome. No amount of structure stops a vendor splitting one material field into two that each look informational, as amount_hint and amount_scale do. The fix is a review of the schema shape itself. Clever canonicalisation does not close that gap.

Canonicalisation is where subtle bugs live, and the precision one is the worst. JavaScript cannot represent integers above 2^53 exactly. A monetary amount of 9007199254740993 that passes through a JavaScript client arrives as 9007199254740992, with no error, no warning, and a digest that faithfully records the wrong number. This is the practical reason to represent money as a bounded integer in minor units with a declared per-call ceiling: the ceiling and the unit make the pathological value unrepresentable instead of merely unlikely.

An allowlist, if you take design one, is also a maintenance commitment. Every entry is a claim that a field does not affect the action, and that claim is true only until the tool’s semantics change. At that point it is the schema drift problem arriving through the classification layer instead of the identity layer. That is the argument for design two on any envelope you control.

And there is no classification for fields the tool has not declared. A schema-blind tool that accepts arbitrary JSON cannot be classified at all, which is why “unknown fields are material until reviewed” has to be the default from the start.

FAQ

Why not hash the entire payload?

A free-form argument bag is design one. Any change to a live approval invalidates it, and operators stop approving anything once the refusals become unexplainable. That reasoning does not settle the question, because the cost lands on correctness rather than convenience: a payload that is not in the hash cannot fail to match.

Which of the two is right depends entirely on whether you control the envelope’s shape. A bag you do not control needs a classification. An envelope you do control should be hashed whole, and the discipline moves from maintaining a list to keeping the type small.

Should informational fields be included in the hash at all?

On a closed typed envelope, yes, and the reason is that there are none to exclude. An envelope that carries a field with no bearing on the decision is a field you should delete from the type, not one you should carve out of the digest. Once the shape is narrow enough that every field is a decision-relevant fact, the hash covers all of it, unconditionally, and there is no list in the system that can fall out of date.

Does this belong in the policy definition or the code?

The canonicalisation is code, and it has to be: a configurable canonicaliser is a way to make two different actions hash identically. Classification, where you need one, is data. It is a property of the tool and belongs with the tool’s registration.

How do we classify fields on a tool with fifty parameters?

Most are informational by elimination: read them, and any field you cannot construct a scenario for where changing it alters the effect goes on the material list by default. The allowlist direction means you only have to make a confident argument for the small set, instead of a confident argument for every field.

What happens when the tool schema changes?

On a hashed-whole envelope, the schema cannot change without a type change, and a new required field is a new action shape that needs a new approval, which is the correct outcome. Where a classification exists, it is versioned with the schema: a new field is material until reviewed, and a field whose meaning changed is a different field as far as the approval is concerned.

Where this runs

NullRun binds the whole envelope, and the envelope is a closed type. It is not a bag. The wire form is an enum, Money or ToolCall, and every field of the selected variant is canonicalised and hashed:

SHA-256( "nullrun/v1/business_impact:" || canonical_json(envelope) )

The domain prefix is the part worth naming. It means a digest computed for an action can never collide with a digest computed for anything else in the system, so a hex string from one namespace cannot be replayed into another. That is what you want from a value that is going to be stored, compared and trusted.

Three properties follow from hashing the whole thing:

Canonicalisation sorts object keys recursively and leaves arrays in their order, for the reason above: an object’s key order carries no meaning, and whether an array’s order does is a question only the schema can answer.

The operational consequence is the one that matters. Changing an amount changes the digest and invalidates the prior approval, so the second call needs a new decision. That is the intended behaviour. The operator approved a specific number, and a different number is a different action. The API reference carries the wire shapes.