Part II — CS Building Blocks · Chapter 15
Schemas as Contracts
Schemas as Contracts
A schema that only validates on write is an aspiration. A schema that validates on every read and write is a contract.
ℹPrerequisites▼
Before reading this chapter, you should be comfortable with: Chapters 5–7 (Hashing, Signatures, Canonicalization). Schema validation enforces the invariants that hashing, signing, and canonicalization establish.
A schema is a specification made executable. Given a schema and a data structure, the question "is this data structure valid?" has a computable answer. That is the entire point. Not "does this look reasonable," not "did the engineer who wrote it intend for the field to be present" — a binary, deterministic answer that a machine can produce in milliseconds and that a test suite can enforce on every commit.
Canon's schema serves three purposes. It expresses the Canon specification as code: R1, R3, R5, and R6 are not just prose in a PDF — they are Python model_validator methods that raise ValueError with precise diagnostics when a construction attempt violates them. It acts as a boundary: data that has not been validated by the schema is not a Canon attestation, regardless of what label is attached to it. And it provides documentation that cannot fall out of sync with the implementation, because the schema is the implementation. This chapter builds Canon's Pydantic implementation from first principles: what schema validation gives you, where JSON Schema ends and Pydantic begins, and how R1, R3, R5, and R6 are enforced at construction time rather than at the verification step. It closes with the failure mode most systems ignore: schema drift, where schema and implementation diverge until neither is authoritative. ## At a glance - R1 (structural validity) is a binary precondition. An attestation that fails R1 is not a valid Canon artifact; it cannot be sealed, signed, or admitted as evidence. - Pydantic v2's model_validator(mode='after') and field_validator enforce R3, R5, and R6 at construction time — before any data reaches the signing layer. - Canon v0.2.0 adds DSSEEnvelope and DSSESignature as new Pydantic models representing the outer signing envelope. Both live in meridian/canon/schema.py. - A schema violation caught at construction time is a 10-second fix. A schema violation caught at the verifier (step 1 of the seven-step protocol) requires a complete re-issuance: a new chain hash, a new signature, a new seal. ## Learning objectives - Explain the difference between JSON Schema (declarative, language-agnostic) and Pydantic (executable, Python-native), and when each is the right tool. - Read and interpret Pydantic v2 model definitions: ConfigDict, Field, field_validator, model_validator. - Identify which Canon requirements (R1, R3, R5, R6) are enforced at the schema layer and which are enforced elsewhere. - Construct attestations that violate specific requirements and observe the Pydantic error messages. - Set up a CI job that detects schema drift before it reaches production. --- ## Why schemas at all Schemas add code, add dependencies, and add a new failure mode: the schema says a field is a string, but the field is None, the object fails to construct, and the exception propagates up the call stack. Code that worked before the schema breaks after it. This is the point. A schema that breaks previously working code is doing its job: it is exposing data the rest of the system was silently handling incorrectly. The three failure modes schemas prevent are worse than the one they introduce. Failure mode 1: silent type corruption. Without a schema, an integer field populated with a string does not fail immediately. It fails when some code path downstream calls .days_until(...) on it, three stack frames from the origin, with a AttributeError: 'str' object has no attribute 'days_until' that is impossible to trace. With a schema, the failure is at the object boundary, with a message that names the field, the expected type, and the received value. Failure mode 2: missing required fields. The chain_hash field is required. Without a schema, code that constructs an attestation and forgets to populate chain_hash produces an object that passes every in-memory check, writes to the database, gets signed, gets transmitted, and then fails at step 3 of the reference verifier in the recipient's environment. The recipient's error message is "chain hash mismatch." The recipient concludes the attestation was tampered with. The actual problem was an absent field. With a schema, construction fails immediately with "field required: chain_hash." Failure mode 3: structural violations that are invisible until runtime. A claim that references an observation ID that does not exist in the witness block is structurally invalid. Without a schema validator, this violation is invisible until the verifier runs step 5 (supports closure) and reports that a supports reference did not resolve. That failure happens at the recipient, not the emitter. The emitter has to reissue. With a model-level validator, the failure happens at construction time, in the emitter's process, before any signing is attempted. All three failure modes share a structure: the error is caught late, by the wrong party, with insufficient context. Schemas move the failure earlier, closer to the origin, with more context. --- ## JSON Schema and Pydantic: the right tool for each job JSON Schema 2020-12 is a declarative language for describing the structure of JSON documents. You write a schema in JSON (or YAML), and a validator library checks a document against it. The schema is language-agnostic: the same canon.schema.json can be validated by Python, JavaScript, Go, and Rust validators, all producing the same result on the same document. Pydantic v2 is a Python library that enforces structure at object construction time. You define classes that inherit from BaseModel, annotate their fields with Python type hints, and Pydantic validates incoming data when you call Model(field=value). Validation happens in Python, at the Python object boundary, with Python exceptions. The two tools are complementary, not competing. > ◆ Going Deeper — Why Canon uses Pydantic, not JSON Schema, as the primary authority. > > Canon could use JSON Schema as the sole validator: emit the attestation as a Python dict, run a JSON Schema validator over it, and only then proceed to signing. This is how many REST APIs work. The advantage is language-agnostic portability — any recipient who has the JSON Schema can validate any attestation. > > Canon uses Pydantic as the primary authority for a different reason: the validators that enforce R3, R5, and R6 are not expressible in JSON Schema. JSON Schema can check that a field is present, that it matches a pattern, that an array is non-empty. It cannot check that every supports entry in a Claim resolves to an observation_id defined earlier in the same document's witness block. That cross-field, cross-array, ordering-dependent check requires code. > > The correct architecture is: Pydantic enforces R1 through R6 at construction time, in Python. A JSON Schema (if provided) is the wire-format authority for receivers in other languages. The two must agree on R1. For R3, R5, and R6, the reference verifier (Chapter 25) is the authority for all languages, because it implements the seven-step protocol in Python and (in Lab 25) in a second language. > > Pydantic is not a portability layer. It is the co-located enforcement layer for the emit pipeline. The distinction matters for how you reason about where bugs live. --- ## Pydantic v2 mechanics Canon's schema is in meridian/canon/schema.py. The four Pydantic constructs used throughout are BaseModel, Field, field_validator, and model_validator. Each plays a distinct role. BaseModel is the base class. Subclasses of BaseModel have automatic __init__ that accepts keyword arguments, validates them, and raises ValidationError with structured error messages if validation fails. Field(...) declares a field with constraints. The first positional argument is the default (... means required). Keyword arguments include pattern (a regex the string must match), min_length, max_length, description, and default_factory. The Field declaration is also where Canon encodes the acceptable values for string fields at a character level: observation_id must match ^obs-[A-Za-z0-9_-]+$, not because the database cares, but because the verifier uses the pattern to identify observation references by prefix. field_validator is a classmethod decorator that runs after the default type coercion for a specific field. Canon uses it for the canon_version field:
# meridian/canon/schema.py — field-level validation
# >>> ch8 start
@field_validator("canon_version")
@classmethod
def _version(cls, v: str) -> str:
if v != CANON_VERSION:
raise ValueError(
f"Unsupported canon_version {v}; this verifier supports {CANON_VERSION}"
)
return v
# <<< ch8 end
This validator runs before model_validator. If the version is wrong, construction fails before the cross-field validators are even attempted. That ordering matters: there is no point validating R3 on an attestation that declares an unsupported Canon version. model_validator(mode='after') is an instance method that runs after all field-level validators have passed. It receives the fully constructed (but not yet frozen) model instance. This is where the cross-field requirements live. --- ## The four requirements enforced at construction time ### R1 — Structural validity R1 is enforced implicitly by the Pydantic model definition. Every required field is declared with ... as the default. Every field has a Python type annotation. Every string field with a constrained format has a pattern regex. A Attestation(...) call that omits a required field, passes a value of the wrong type, or passes a string that does not match the pattern raises a ValidationError before __init__ completes. R1 also covers the nested structure. Attestation contains a Refutation, which contains a list of Challenge objects, each of which has its own field constraints. Pydantic validates the entire tree at construction time. A Challenge with an empty targets list — which Canon prohibits — fails at the Challenge level, not at the Attestation level, and the error message names the Challenge. ### R3 — Supports closure Every Claim has a supports list: the IDs of observations or prior claims that the claim is inferred from. R3 requires that every entry in supports resolve to either an observation_id in the attestation's witness block or a claim_id defined earlier in the findings.claims list. Forward references are prohibited. Dangling references — supports that point to IDs that do not exist anywhere — are prohibited.
# meridian/canon/schema.py — R3 enforcement
# >>> ch8 r3
@model_validator(mode="after")
def _supports_closure(self) -> "Attestation":
observation_ids = {w.observation_id for w in self.witness}
seen_claim_ids: set[str] = set()
for claim in self.findings.claims:
for support in claim.supports:
if support in observation_ids:
continue
if support in seen_claim_ids:
continue
raise ValueError(
f"Claim {claim.claim_id} references unresolved support {support} (R3)"
)
seen_claim_ids.add(claim.claim_id)
# <<< ch8 r3
This is the R3 validator — _supports_closure checks that claim.supports is non-empty (enforced at the Claim field level via min_length=1) and that every entry in supports resolves to either an observation_id in the witness block or a claim_id defined earlier in findings.claims. The forward-reference prohibition falls out naturally: seen_claim_ids only contains IDs of claims already processed, so a reference to a claim defined later in the list fails the check. Critically, this same _supports_closure method on Attestation also enforces the R6 challenge-target check described in the next section — both checks live in one method body. Read on. ### R5 — Gap disclosure Non-observational claims must enumerate their epistemic gaps. A claim of inference type DEDUCTION that asserts "the parent was home at 14:00" must also declare what it does not know — "this claim is not corroborated by independent records" or "GPS data for this period was not produced." R5 prevents a claim from projecting false certainty by simply omitting the uncertainties.
# meridian/canon/schema.py — R5 enforcement
# >>> ch8 r5
@model_validator(mode="after")
def _gap_disclosure(self) -> "Claim":
if self.inference_type != InferenceType.OBSERVATION and not self.gaps:
raise ValueError(
f"Claim {self.claim_id} of inference_type {self.inference_type} "
"must declare at least one gap (R5)"
)
return self
# <<< ch8 r5
This validator is on the Claim model, not the Attestation model. It runs when each Claim is constructed, before the Attestation-level _supports_closure runs. The ordering is Pydantic's: nested models are validated bottom-up, then the top-level model_validator runs. ### R6 — Refutation completeness Every attestation must include a Refutation block with at least one Challenge and a Coverage object that inventories both the applied and declined challenge types. A challenge that targets a claim_id not defined in the findings block is a malformed attestation — you cannot challenge a claim that does not exist. The Refutation model enforces the presence of at least one challenge at construction time:
# meridian/canon/schema.py — R6 enforcement
# >>> ch8 r6
class Refutation(BaseModel):
challenges: list[Challenge] = Field(..., min_length=1)
coverage: Coverage
@model_validator(mode="after")
def _refutation_complete(self) -> "Refutation":
if not self.challenges:
raise ValueError("Refutation must contain at least one Challenge (R6)")
return self
# <<< ch8 r6
The cross-field check — that challenge targets resolve to defined claim IDs — is also inside the Attestation-level _supports_closure method, appended after the R3 loop. This is a separate validator enforcing R6 — it checks that challenge.targets[*] resolve to defined claim_id values in findings.claims. It is distinct from the R3 supports-closure check above even though both live in the same _supports_closure method body. In schema.py, after the R3 loop completes and seen_claim_ids is fully built, a second loop over self.refutation.challenges checks each target against seen_claim_ids and raises if any target is unresolved. The two validators together enforce R6 completely: the refutation block is present and well-formed (at the Refutation level via _refutation_complete), and its targets are resolvable (at the Attestation level via the R6 block in _supports_closure). --- ## Runtime enforcement on READ matters The four requirements above are enforced at construction time — when Attestation(...) is called. But there is a second moment where schema validation is needed that is often overlooked: when a stored or transmitted attestation is read back. Consider the sequence: an attestation is emitted by a conformant emitter, stored in a database as a JSON blob, and later retrieved by a different application that calls Attestation(**json.loads(blob)). If the emitter was conformant, this roundtrip succeeds. But what if the stored blob was written by an earlier version of the emitter, before R5 was added to the schema? What if the blob was hand-edited in the database to fix a typo? What if the blob was received from an external system that claimed to be Canon-conformant? The answer is the same in all three cases: the Attestation(...) constructor validates on read. If the blob violates the current schema, construction fails with a ValidationError. The failure is detected at the application boundary, not three call frames later when some code tries to iterate over claim.gaps and finds an empty list it did not expect. This is why the Attestation model is not used merely as a serialization format (a dataclass would suffice for that) but as an enforcement boundary. Every path that produces an Attestation object — emission, retrieval, deserialization — passes through the same validators. --- ## Schema drift and CI detection Schema drift is the natural resting state of two representations of the same structure maintained in different files by different engineers at different times. It is not a failure of discipline; it is a physical inevitability whenever the same information is represented twice. The correct mitigation is not more discipline. It is a CI job that fails when drift is present. For Canon, the drift-detection strategy is fixture-based: a set of canonical attestation JSON files live in meridian/canon/tests/fixtures/. Each fixture is a known-valid or known-invalid attestation. The CI job runs two checks: 1. Attestation(**json.loads(fixture)) succeeds for all known-valid fixtures and fails (with the expected error) for all known-invalid fixtures. 2. If a JSON Schema validator is available, it produces the same verdict on each fixture as the Pydantic model. If a change to schema.py breaks a known-valid fixture, the CI job catches it. If a change to the JSON Schema breaks the JSON Schema validator's verdict without a corresponding change to schema.py, the CI job catches it. The fixtures are the ground truth; the two validators must agree on them. Adding a new required field to the schema without a migration plan for existing stored attestations is a common source of drift-adjacent bugs. The CI job does not catch this (because the fixtures were written after the field was added and are therefore valid under the new schema) — but the first Attestation(**json.loads(blob)) call against a pre-migration stored blob will. The correct approach: add new required fields with a default value, run a migration to backfill existing rows, and only then make the field truly required in the schema. --- > ☉ In the Wild — Heartbleed (CVE-2014-0160). > > In April 2014, the Heartbleed vulnerability was disclosed in OpenSSL's implementation of the TLS heartbeat extension. The attack was simple: a client sent a HeartbeatRequest message with a payload_length field that claimed the payload was 64 KB, but the actual payload was 1 byte. OpenSSL's implementation copied payload_length bytes from a server-side buffer and sent them back. Those bytes included private key material, session tokens, and passwords from other connections. > > The vulnerability was not a cryptographic weakness. It was an absent validation: the HeartbeatRequest handler did not check that the actual payload length matched the claimed payload length before reading. There was no schema. There was no boundary check. The struct's fields were trusted as accurate descriptions of the data they accompanied. > > This is the opposite of a schema contract. A schema that enforced "the actual payload length must equal the declared payload_length" would have caught the malformed request at the parser, before any memory was read. The constraint was not complicated — it was a two-field cross-check, exactly the kind of thing a model_validator enforces. The constraint was simply absent. > > Heartbleed is not presented here as a warning about cryptography. It is presented as a warning about the cost of missing validation at the data boundary. That cost, in 2014, was approximately 17% of all HTTPS servers on the internet. --- ## Working example > ✻ Try This — Violate R3 at construction time. > > Open a Python shell. Import the Canon schema. Construct a minimal Attestation where a claim's supports list references an observation ID that does not exist in the witness block.
from meridian.canon.schema import Attestation, WitnessEntry, Findings, Claim from meridian.canon.schema import InferenceType, Refutation, Challenge, Coverage from meridian.canon.schema import ChallengeType, ChallengeOutcome, AttestationKind # This construction will raise a ValidationError. attestation = Attestation( attestation_id="01JTEST0000000000000000000", kind=AttestationKind.OBSERVATION, issued_at="2026-03-01T00:00:00Z", issuer="test", subject="test", witness=[WitnessEntry( observation_id="obs-001", source="file://test.txt", received_at="2026-03-01T00:00:00Z", content_hash="sha256:" + "a" * 64, content_inline="dGVzdA==", )], findings=Findings(method="manual", claims=[Claim( claim_id="claim-001", statement="The document exists.", supports=["obs-DOES-NOT-EXIST"], # dangling reference inference_type=InferenceType.OBSERVATION, )]), refutation=Refutation( challenges=[Challenge( challenge_id="chal-001", type=ChallengeType.CONSISTENCY_CHECK, targets=["claim-001"], input="Does the document exist?", outcome=ChallengeOutcome.SURVIVED, )], coverage=Coverage(applied=[ChallengeType.CONSISTENCY_CHECK], declined=[]), ), )You will see a
ValidationErrorthat namesclaim-001, the unresolved referenceobs-DOES-NOT-EXIST, and the requirement(R3). The error fires atAttestation(...), before any signing, before any storage. > > Now change"obs-DOES-NOT-EXIST"to"obs-001". Construction succeeds. --- > ▼ Why It Matters — The difference between a 10-second fix and a re-issuance. > > In the 2026 TPR proceeding, the parent's advocate has submitted 47 attestations into the record. Two weeks after submission, opposing counsel's expert runs the reference verifier and reports that attestation01JXYZ...fails step 1: schema validation fails because thesupportsfield of claim 3 referencesobs-004, which does not appear in the witness block. > > The advocate now has a problem that is simultaneously minor and serious. The fix is minor: theobs-004reference is a typo; it should beobs-003. Correcting the reference takes 10 seconds. But the attestation has already been signed, and the seal commits to the chain hash, which commits to the entire content including the typo. The fix requires constructing a new attestation, computing a new chain hash, producing a new signature, and resubmitting with an explanation. > > If the schema validator had caught this at construction time — when the advocate's system first built the attestation — the error would have appeared as aValidationErrorbefore any signing occurred. The 10-second fix would have taken 10 seconds. The resubmission, the explanation to the court, and the opposing expert's written report would not exist. > > This is the asymmetry that justifies the cost of schema enforcement. Catching at construction is cheap. Catching at the verifier is expensive, visible, and in an adversarial context, damaging. --- ## DSSEEnvelope and DSSESignature (v0.2.0) Canon v0.2.0 adds two new schema models that represent the outer signing envelope (Chapter 6). These live inmeridian/canon/schema.pyalongside the existing attestation models.
# meridian/canon/schema.py — v0.2.0 additions
# >>> ch8 dsse
class DSSESignature(BaseModel):
keyid: str = Field(..., description="SHA-256 fingerprint of the signing key")
sig: str = Field(..., description="base64url-encoded Ed25519 signature over PAE(payload_type, canonical_bytes)")
public_key_url: str = Field(..., description="Stable URL for the PEM-encoded public key (RFC 8410)")
class DSSEEnvelope(BaseModel):
payload_type: str = Field(
default="application/vnd.nora.canon.attestation+json; version=0.2.0",
description="MIME type identifying the payload as a Canon attestation"
)
payload: str = Field(..., description="base64url-encoded JCS(attestation) bytes")
signatures: list[DSSESignature] = Field(..., min_length=1)
chain_hash: str = Field(
...,
pattern=r"^sha256:[0-9a-f]{64}$",
description="SHA-256 of decoded payload bytes — convenience field for quick integrity check"
)
@model_validator(mode="after")
def _chain_hash_consistent(self) -> "DSSEEnvelope":
"""Verify chain_hash matches the decoded payload at construction time."""
import base64, hashlib
try:
decoded = base64.urlsafe_b64decode(self.payload + "==")
except Exception as e:
raise ValueError(f"DSSEEnvelope.payload is not valid base64url: {e}")
expected = "sha256:" + hashlib.sha256(decoded).hexdigest()
if expected != self.chain_hash:
raise ValueError(
f"DSSEEnvelope.chain_hash {self.chain_hash!r} does not match "
f"SHA-256 of decoded payload {expected!r}"
)
return self
# <<< ch8 dsse
Two structural points worth noting:
payload_type is a falsifiable claim. A verifier that expects a Canon v0.2.0 attestation and receives an envelope with a different payload_type string rejects the envelope without touching the signature. This is the algorithm-substitution defense at the envelope level, parallel to the canonicalization and signature_algorithm fields inside the Seal block. _chain_hash_consistent fires at construction time. Any code that builds a DSSEEnvelope with a mismatched chain_hash will see a ValidationError immediately. This enforces the discipline from Chapter 7: canonical_bytes must be used consistently across all three fields — payload, chain_hash, and the signature. ## Schema and the wire format The Pydantic model is Python. The attestation travels as JSON. Pydantic's model_dump() produces a Python dict; model_dump(mode='json') handles UUID and datetime serialization. The round-trip from Pydantic to JSON and back is:
# Round-trip example — for illustration only
attestation_dict = attestation.model_dump(mode="json")
attestation_json = json.dumps(attestation_dict)
restored = Attestation(**json.loads(attestation_json))
assert restored == attestation
The round-trip works because Pydantic's serialization and deserialization are symmetric for the types used in Canon's schema. The one place where asymmetry can appear: Python Enum fields. Pydantic serializes an InferenceType.OBSERVATION as the string "observation"; deserialization converts the string back to the enum. If the JSON Schema is written to accept only "observation" (not the enum member), the two are consistent. If the JSON Schema is written to accept the enum class's name ("OBSERVATION"), they diverge. Canon uses the .value serialization throughout. --- ## Lab 8 Goal: Understand the schema's enforcement boundaries by constructing both valid and invalid attestations and documenting the exact Pydantic errors. Deliverables: Construct 10 attestation attempts. For each, document: the specific violation (or confirmation of validity), the exact ValidationError or ValueError raised (copy the message), and which Canon requirement (R1, R3, R5, R6) was violated. The 10 cases: 1. Valid — a minimal conformant attestation with one witness entry, one claim, one challenge. 2. R1: missing required field — omit attestation_id. 3. R1: wrong type — pass an integer for issued_at. 4. R1: pattern violation — pass "observation-1" as an observation_id (does not match ^obs-[A-Za-z0-9_-]+$). 5. R1: empty witness — pass an empty list for witness. 6. R3: dangling support reference — a claim whose supports references an ID not in the witness block. 7. R3: forward reference — two claims where claim-001's supports references claim-002, which is defined later. 8. R5: inference without gaps — a claim with inference_type=DEDUCTION and an empty gaps list. 9. R6: empty challenges — a Refutation with challenges=[]. 10. R6: challenge targets non-existent claim — a challenge whose targets includes an ID not defined in findings. After completing the 10 cases: run the reference walker (meridian-canon walk) against the valid attestation from case 1 (with a proper seal added). Confirm step 1 passes schema validation. Document the walker's output. Acceptance criteria: All 10 cases are documented with exact error messages, the valid attestation walks successfully, and a brief findings.md summarizes which validator (field-level or model-level) caught each violation. ---
DSSEEnvelope and DSSESignature model the outer signing envelope with a _chain_hash_consistent model validator that decodes payload and recomputes SHA-256 at construction, making a mismatched chain_hash impossible to store silently. - The _supports_closure model validator on Attestation enforces R3 (every supports entry resolves to a prior observation or claim without forward references) and the R6 challenge-target check (every challenge target resolves to a defined claim) in a single method body. - Schema validation belongs BEFORE signing because the Ed25519 signature commits to the chain hash of the canonical content — correcting a structural error after sealing requires a complete new chain hash, new signature, and new seal. - strict=True (via model_config = ConfigDict(strict=True)) prevents silent type coercion — a string "1" passed where an int is expected raises ValidationError instead of silently becoming 1, which is the coercion class that produces cross-platform hash mismatches. meridian/canon/schema.py, write the Pydantic model definition for WitnessEntry from the field descriptions in this chapter. Include all field constraints. Then compare your version to the source. Note any differences and explain them. 2. Open meridian/canon/schema.py. Find every model_validator(mode='after') in the file. For each, identify which Canon requirement it enforces and which model class it lives on. List them in order of execution depth (deepest nested first). ### Core 3. Construct the six attestations in Lab 8 cases 5–10. For each, confirm the ValidationError and document which line of schema.py raised it. For cross-field validators, trace the exception back to the specific raise ValueError(...) statement. 4. Add a new field to a copy of the WitnessEntry model: acquired_by: str with the constraint that it must match ^actor-[A-Za-z0-9_-]+$. Write a fixture that satisfies the new constraint and a fixture that violates it. Run both through WitnessEntry(...). Then explain the migration challenge: what happens to existing stored attestations when this field becomes required? 5. Construct an Attestation object in Python with a valid witness block but with findings.claims containing one claim whose inference_type is 'observed' and supports is an empty list. Verify that schema construction raises a ValidationError. Write one sentence identifying which requirement (R1–R6) this validator enforces and why an observation claim must have at least one supporting element. ### Stretch 5. Write a property-based test using Hypothesis that generates random Python dicts and feeds them to Attestation(**d). For each attempt, record whether Attestation(...) raised a ValidationError, a ValueError from a model validator, or succeeded. Run for 1,000 iterations. Report how many succeeded. Inspect one or two successes — do they look like valid attestations or did they slip through on technicalities? 6. Implement canon-validate, a CLI tool that reads an attestation JSON from a path and reports R1–R6 conformance with line-precise error messages: FAIL R3: claim claim-001 references unresolved support obs-999. Run it over the test fixtures in meridian/canon/tests/. Compare its output against the reference walker's step 1 and step 5 output on the same files. --- ## Build-your-own prompt For your capstone attestation kind, answer two questions. First, what domain-specific fields would you add beyond Canon's required set? For each, write the Pydantic field declaration and any field_validator or model_validator logic needed to enforce its constraints. Second, are any of your new fields load-bearing for falsifiability — meaning a verifier could not reach a correct verdict without them — or are they metadata that could be omitted without changing the cryptographic guarantees? The distinction matters for whether the field belongs in the signed payload or in an accompanying sidecar document. --- ## Further reading - JSON Schema 2020-12 specification, https://json-schema.org/draft/2020-12. The declarative wire-format authority for Canon-compatible validators in other languages. - Pydantic v2 documentation, https://docs.pydantic.dev/2.0/. The primary reference for model_validator, field_validator, and ConfigDict. - meridian/canon/schema.py — the complete Canon Pydantic implementation. Read it alongside this chapter.
- Schneier, "Heartbleed" (Schneier on Security, 2014), https://www.schneier.com/blog/archives/2014/04/heartbleed.html. Accessible explanation of the Heartbleed vulnerability for non-specialists.
- CVE-2014-0160 original disclosure, https://heartbleed.com. The primary source.
- Kleppmann, Designing Data-Intensive Applications (O'Reilly, 2017), Chapter 4 — Encoding and Evolution. The canonical treatment of schema evolution in production systems, including backward and forward compatibility.
- Meridian-Canon-Revised.tex §5 — the conformance requirements R1–R9 in the original specification.
Previous: Chapter 7 — Canonicalization (RFC 8785). Next: Chapter 9 — The Emit Pipeline.