30-second answer
JSON permits the same string to be written more than one way. Python's
default writer escapes non-ASCII (ensure_ascii=True); .NET's default
encoder escapes differently. Both outputs parse back to the same
document, and their SHA-256 digests differ. The engine hit this over ten
apostrophes: two correct implementations, one document, two digests. The
fix is to make the byte form part of the contract: sorted keys, no
whitespace, minimal escaping, written into the schema description
itself, so every implementation serializes the same bytes before
hashing.
What this proves
- That "valid JSON" is not an identity. Validity is about the parsed value; a digest is about bytes. Any pipeline that hashes JSON without fixing the byte form has an identity that depends on which runtime wrote the file.
- That the contract can carry the fix. The canonical form lives in the schema description, so a C# consumer and a Python producer read the same sentence, and the engine's contract bundle ships with generated models whose headers list the constraints no C# type can express.
What it does not prove
- That two digests matching means the content is right. A digest binds bytes, not truth. Matching digests prove nobody touched the document, not that the document was worth writing.
- That validation alone is enough. The engine's own ordering is explicit: validate, then deserialize; neither alone is the contract.
PowerShell
# The same value, two byte forms, two digests
$s = '{"name": "O''Neill''s list"}'
$py = python3 -c "import json,sys;print(json.dumps(json.loads(sys.argv[1])))" $s
$net = ($s | ConvertFrom-Json | ConvertTo-Json -Compress)
($py, $net | ForEach-Object {
(Get-FileHash -InputStream ([IO.MemoryStream]::new(
[Text.Encoding]::UTF8.GetBytes($_))) -Algorithm SHA256).Hash.Substring(0,12)
})Example output
7B0E9A6C21D4
3F41C08872AAExplanation
The failure is silent by design: nothing errors, nothing warns, both
sides keep validating, and only the digest comparison at the end says
the two halves of your system disagree. That is why the engine treats
escaping policy as contract, not style. Once the canonical form is
fixed, a digest becomes portable: the assessment_id computed by the
Python engine is the assessment_id any other correct implementation
computes, and the shipped contract bundle can be verified byte-identical
against the repository copy.
Production considerations
- If you sign or hash JSON anywhere, write the canonical form down where every implementation must read it. "We use the platform default" is two defaults the day you add a second platform.
- Sort keys at the writer, not the comparer. A comparer that normalizes is a comparer that hides what the writer did.
Related governance rules
- None directly. The canonical form underwrites the identity of every Assessment that carries any rule's results.
References
- Schemas, with the canonical form in the descriptions (m365-governance-as-code)
- Generated contract models (m365-governance-as-code)
Found something wrong? Suggest a correction. The article source is not public; the engine it cites is.