diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f26f89..33f156b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,14 +107,14 @@ which. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1 validates every fenced `jsonc` block in `SPEC.md` too (comments and documented placeholders normalized away, structure checked), and CI's existing `schema` job therefore catches this class of drift. -- **JSON Schema: an ordinary `ContextQuery` was un-representable.** `kinds` and - `anchors` were listed as `required` while both are - `skip_serializing_if = "Vec::is_empty"` in the reference type, so an - unfiltered, unanchored query — what a host sends when it wants anything - relevant — failed schema validation. Exactly the bug fixed for `ContextFrame` - below, one type over. A test now asserts an ordinary query satisfies the - schema's own `required` array, and a cross-audit of all 16 shared types - confirms no other type demands a field its serializer elides. +- **Regression guard for the `ContextQuery` `required` fix.** The schema change + itself landed independently in #63; this adds the test that keeps it fixed — + an ordinary unfiltered, unanchored query must satisfy the schema's *own* + `required` array (read from the schema, so it cannot drift into a stale + snapshot). A cross-audit of all 16 shared types confirms no other type demands + a field its serializer elides — this bug class has now recurred twice + (`ContextFrame` in PR #44, `ContextQuery` in #63), so it is worth a standing + check rather than another one-off fix. - **§G2 and §D1 are now actually verified, not merely asserted.** Both named `frame-validity` as their verifier while neither `target_uri` nor the frame's own `content_digest` was read by any check — the self-attestation §11.1 diff --git a/schema/contextgraph-envelope.schema.json b/schema/contextgraph-envelope.schema.json index 512095e..488739a 100644 --- a/schema/contextgraph-envelope.schema.json +++ b/schema/contextgraph-envelope.schema.json @@ -441,7 +441,7 @@ "max_frames", "max_tokens" ], - "$comment": "Required is exactly what the reference serializer (contextgraph-types ContextQuery) always emits: goal, max_frames, max_tokens. kinds and anchors are skip_serializing_if=Vec::is_empty, so an unfiltered, unanchored query — the most ordinary query there is — omits them; listing them as required made that query un-representable in conformant JSON. Same bug class as the ContextFrame required list below (PR #44): the schema must not demand fields the canonical serializer elides.", + "$comment": "Required is exactly what the reference serializer (contextgraph-types ContextQuery) always emits: goal, max_frames, max_tokens. `kinds` and `anchors` are skip_serializing_if=Vec::is_empty in the reference type, so an unfiltered, unanchored query — the most common query there is, and the one the conformance suite's own sample_query() sends — serializes without them. Listing them as globally required made that query un-representable in conformant JSON, the same class of bug ADR 0006 surfaced on ContextFrame. An empty array remains valid; absence and [] mean the same thing (no filter).", "additionalProperties": false }, "ContextQueryResult": { diff --git a/schema/validate-examples.py b/schema/validate-examples.py index 32c7cb7..3e11a23 100755 --- a/schema/validate-examples.py +++ b/schema/validate-examples.py @@ -5,15 +5,26 @@ Usage: python3 schema/validate-examples.py -Exits 0 if every message in examples/ AND every fenced example in SPEC.md is -valid under schema/, and 1 otherwise. +Exits 0 if every message in examples/, every reference-serialized vector, and +every fenced example in SPEC.md is valid under schema/ — and the schema's `$id` +resolves to a byte-identical served copy. Exits 1 otherwise. No third-party dependencies beyond `jsonschema` (pip install jsonschema). -SPEC.md is checked because it was the one example surface nothing validated, and -it drifted: the §9 `verify` example carried an `id` member that §3.2 grants only -to `query`/`frames`/`error`, that the reference envelope has no field for, and -that the schema's `additionalProperties: false` rejects. The spec's own examples -are now held to the spec's own schema. +The four example surfaces are deliberately different in kind: + + * `examples/` is hand-authored — it proves the schema accepts what a human + writes, and is what a provider author diffs against. + * `reference-vectors.ndjson` is machine-serialized from the reference Rust + types — it proves the schema accepts what the canonical serializer actually + emits (issue #54). Curated examples cannot catch that class. + * `SPEC.md` is the normative document. It is checked because it was the one + example surface nothing validated, and it drifted: the §9 `verify` example + carried an `id` member that §3.2 grants only to `query`/`frames`/`error`, + that the reference envelope has no field for, and that the schema's + `additionalProperties: false` rejects. The spec's own examples are now held + to the spec's own schema. + * the served `$id` copy is the schema's public identity — checked because a + stale schema that still resolves is worse than one that 404s. """ import json import re @@ -64,14 +75,40 @@ def check(label: str, ok: bool) -> None: continue check(f"{ref_path.name} message {i} ({msg['type']})", True) -# 3. SPEC.md's own fenced examples. +# 3. Reference-SERIALIZED vectors — one envelope per line, generated by +# `contextgraph-conformance/tests/reference_vectors.rs` from the reference +# Rust types rather than authored by hand. # -# These are jsonc — `//` comments, and prose placeholders standing in for values -# whose real form is uninteresting to a reader (`"…"`, `sha256:<64 hex>`). Both -# are normalized away before validation, because what is being checked here is -# STRUCTURE: member names, required members, types, and above all -# `additionalProperties` — the class of error that put an `id` on a `verify`. -# Substituting a placeholder never invents a member, so it cannot mask that. +# This is the half that catches schema/serializer disagreement (issue #54). +# Curated examples only ever prove the schema accepts what a human wrote; a +# field marked `required` here but omitted by `skip_serializing_if` in the +# Rust type stays invisible until some downstream serializes a real value and +# gets rejected. Validating the serializer's own minimal output — empty vecs, +# `None` options — makes that class of bug a red build instead of a +# downstream bug report. +vectors_path = ROOT / "schema" / "reference-vectors.ndjson" +vector_lines = [l for l in vectors_path.read_text().splitlines() if l.strip()] +if not vector_lines: + sys.exit(f"error: {vectors_path.name} is empty — regenerate it " + "(REGENERATE_VECTORS=1 cargo test -p contextgraph-conformance --test reference_vectors)") +for i, line in enumerate(vector_lines, 1): + try: + jsonschema.validate(json.loads(line), SCHEMA) + except (json.JSONDecodeError, jsonschema.ValidationError) as e: + check(f"{vectors_path.name} line {i}", False) + print(f" {e}") + continue + check(f"{vectors_path.name} line {i} ({json.loads(line)['type']})", True) + +# 4. SPEC.md's own fenced examples. +# +# These are jsonc — `//` comments, and prose placeholders standing in for +# values whose real form is uninteresting to a reader (`"…"`, +# `sha256:<64 hex>`). Both are normalized away before validation, because +# what is being checked here is STRUCTURE: member names, required members, +# types, and above all `additionalProperties` — the class of error that put +# an `id` on a `verify`. Substituting a placeholder never invents a member, +# so it cannot mask that. ENVELOPE_TYPES = { SCHEMA["$defs"][ref["$ref"].rsplit("/", 1)[-1]]["properties"]["type"]["const"] for ref in SCHEMA["oneOf"] @@ -171,5 +208,33 @@ def _skip_ws(text: str, index: int) -> int: continue check(f"{label} ({target})", True) +# 5. The schema's `$id` must dereference to this exact schema. +# +# `$id` is the schema's public identity — the URL third parties resolve and +# quote. It pointed at `context-graph-protocol.org`, a hyphenated host that +# was never registered and returned a DNS failure, so every consumer that +# tried to fetch it got nothing. Now it names the live domain, and the site +# serves the file from `site/public/schema/`. +# +# A served copy can drift from the source of truth, which would be worse than +# a 404: a stale schema that still resolves is one that silently validates +# the wrong thing. So the copy is asserted byte-identical here rather than +# trusted to be refreshed by hand. +SCHEMA_SOURCE = ROOT / "schema" / "contextgraph-envelope.schema.json" +SCHEMA_SERVED = ROOT / "site" / "public" / "schema" / "contextgraph-envelope.schema.json" +expected_id = f"https://contextgraphprotocol.org/schema/{SCHEMA_SOURCE.name}" + +check(f"$id is {expected_id}", SCHEMA.get("$id") == expected_id) + +if not SCHEMA_SERVED.exists(): + check(f"site serves the schema at {SCHEMA_SERVED.relative_to(ROOT)}", False) + print(f" missing — copy it: cp {SCHEMA_SOURCE.relative_to(ROOT)} {SCHEMA_SERVED.relative_to(ROOT)}") + failures += 1 +else: + identical = SCHEMA_SERVED.read_bytes() == SCHEMA_SOURCE.read_bytes() + check("the served schema copy is byte-identical to the source", identical) + if not identical: + print(f" refresh it: cp {SCHEMA_SOURCE.relative_to(ROOT)} {SCHEMA_SERVED.relative_to(ROOT)}") + print(f"\n{'OK — all examples validate' if failures == 0 else f'{failures} failure(s)'}") sys.exit(1 if failures else 0)