From 7ca1d25d3f32613170bcf47655d357dc7ce63d08 Mon Sep 17 00:00:00 2001 From: opento-suggestions Date: Wed, 30 Sep 2026 10:06:16 -0600 Subject: [PATCH 1/2] docs(schema): name both digest algorithms wherever the schema admits both All twelve digest fields in schema/trace-claim.json carry the same pattern shape, ^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$, so each takes sha256: or sha384:. Six of the twelve rows on the schema reference page described their field more narrowly: model.weights_digest, policy.bundle_hash, tool_transcript.hash and build_provenance.digest named sha256: alone, and the two appraisal.re_execution digests named no algorithm. The other six named both, four of them in the form the corrected rows now use. A producer reading one of the narrower rows writes the narrower record, and a verifier author reading one may refuse a record the schema accepts. Section 3.1.3 is where the specification settles a question of this kind: after giving the sha256: construction for delegation.parent_record_hash it names SHA-384 as the permitted alternative, per the schema pattern. The page under-described a constraint its own opening names the schema as the source of. test_schema_doc_coverage.py guarded that every field has a row and every fixed value appears in its field's row. It never read what a row claims about a pattern, so a row could document a field into existence and misdescribe it. It now also fails when a row does not name every algorithm its field's pattern admits. The module docstring drew that boundary deliberately, on the ground that whether a description agrees with the spec is not mechanically decidable. This check does not cross it. Agreement with the field's own pattern is a comparison against a machine-readable constraint, not a judgement about prose, which is why the failure can name the field and the missing algorithm. The docstring now records the distinction rather than leaving the file disclaiming a check it performs. The check asks the pattern with a probe value rather than searching its text. The alternation factors the prefix out, so it contains neither sha256 nor sha384 as text; a search for either finds no algorithm, concludes the field constrains none and skips it. The first version did that and reported no gaps anywhere. A test pins the behaviour. The field set comes from the walk this file already uses. Resolving each field's subschema follows properties and items only, so a member declared solely inside a conditional branch raises rather than being passed over silently. Reverting each corrected row in turn reds the guard and names that field alone, including build_provenance.digest, which shares its member name with two other digest fields. Editorial: no schema, wire-format or normative change. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: opento-suggestions --- CHANGELOG.md | 17 ++++ docs/schema.md | 12 +-- tests/test_schema_doc_coverage.py | 153 +++++++++++++++++++++++++++++- 3 files changed, 173 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddb9985..eeb5482d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,23 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. acceptance behavior. Based on the maintainer ruling and producer feedback from @chernistry and @lywinged in #247. +- **`docs/schema.md` names both digest algorithms wherever the schema admits + both.** All twelve digest fields in `schema/trace-claim.json` carry the same + pattern shape and take `sha256:` or `sha384:`. Six of the twelve rows on the + schema reference page described their field more narrowly than that: + `model.weights_digest`, `policy.bundle_hash`, `tool_transcript.hash` and + `build_provenance.digest` named `sha256:` alone, and the two + `appraisal.re_execution` digests named no algorithm at all, while the other + six named both: four as `sha256:` or `sha384:`, one as `sha256:`/`sha384:`, + and `runtime.measurement` by naming the platform each belongs to. A producer + reading one of the narrower rows writes the narrower record, and a verifier + author reading one may refuse a record the schema accepts. + `tests/test_schema_doc_coverage.py` now fails when a row does not name every + algorithm its field's pattern admits. It asks the pattern with a probe value + rather than searching its text, because the schema factors the prefix out as + `^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$` and so contains neither name. + Editorial: no schema, wire-format or normative change. + ## [0.11.0] - 2026-09-25 - **Breaking:** `TraceSandboxAdapter` and `TraceAGTAdapter` require `enforcement_mode`; diff --git a/docs/schema.md b/docs/schema.md index 3d817216..4b678198 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -45,7 +45,7 @@ Binds the model artifact used in this session. | `provider` | string | **yes** | Model provider (e.g., `example-provider`) | | `model_id` | string | **yes** | Model identifier (e.g., `example-model-1`) | | `version` | string | **yes** | Model version or date stamp | -| `weights_digest` | string | no | SHA-256 digest of model weights artifact | +| `weights_digest` | string | no | `sha256:` or `sha384:` digest of model weights artifact | | `aibom_uri` | string | no | URI to the AI Bill of Materials (SPDX/CycloneDX) | @@ -70,7 +70,7 @@ Binds the governance policy in force during this session. | Field | Type | Required | Description | |---|---|---|---| -| `bundle_hash` | string | **yes** | `sha256:` digest of the Cedar policy bundle bytes | +| `bundle_hash` | string | **yes** | `sha256:` or `sha384:` digest of the Cedar policy bundle bytes | | `enforcement_mode` | string | **yes** | One of: `enforce` (evaluated, blocked on deny), `advisory` (evaluated, logged, allowed), `silent` (evaluated and allowed, including on deny, with operational logs suppressed; the audit chain still records every would-have-denied decision), `declared` (the policy is named and bound into the signed record and nothing evaluated it: the honest value for a producer with no policy engine, never a default, and not evidence that any rule was checked). Defaults to `enforce`. Section 4.3 of the spec defines `enforce`, `silent` and `declared`; `advisory` is in the schema's closed set and its one-line meaning is the schema's own description, not spec text | | `version` | string | no | Policy bundle version string | | `policy_uri` | string | no | URI to the policy bundle for inspection | @@ -93,7 +93,7 @@ Audit summary of tool invocations during the session. | Field | Type | Required | Description | |---|---|---|---| -| `hash` | string | **yes** | `sha256:` of the canonical JSON of the full `AuditEntry` list | +| `hash` | string | **yes** | `sha256:` or `sha384:` digest of the canonical JSON of the full `AuditEntry` list | | `call_count` | integer | **yes** | Number of tool invocations recorded | | `transcript_uri` | string | no | URI to the full per-call transcript (may be encrypted) | @@ -183,7 +183,7 @@ Build-time provenance binding the deployed artifact. |---|---|---|---| | `slsa_level` | integer | **yes** | SLSA provenance level (0 to 3) | | `builder` | string | no | Builder identity URI (e.g., GitHub Actions SLSA generator) | -| `digest` | string | **yes** | `sha256:` digest of the built artifact | +| `digest` | string | **yes** | `sha256:` or `sha384:` digest of the built artifact | | `provenance_uri` | string | no | URI to the SLSA provenance document (e.g., Rekor entry) | | `provenance_depth` | string | no | Depth the issuer claims: `surface`, `builder` or `transitive`. Absent is read as `surface` | @@ -210,9 +210,9 @@ The result of re-running a `reproducibility` claim, made by the party named as ` | Field | Type | Required | Description | |---|---|---|---| | `outcome` | string | **yes** | `reproduced`: the re-run completed on the closure alone and its transcript digest equals `transcript_digest`. `diverged`: it completed on the closure alone and the digests differ. `not-attempted`: a closure blob could not be resolved, `code_identity` could not be obtained, the function read beyond the closure, the re-run did not complete, or the verifier could not establish that it used the closure alone | -| `observed_digest` | string | when `outcome` is `diverged` | The verifier's digest of the transcript its re-run produced. Divergence localises nothing by itself, so the two transcripts have to be comparable by a third party | +| `observed_digest` | string | when `outcome` is `diverged` | The verifier's `sha256:` or `sha384:` digest of the transcript its re-run produced. Divergence localises nothing by itself, so the two transcripts have to be comparable by a third party | | `reason` | string | when `outcome` is `not-attempted` | Why no outcome could be reported. A named absence and a generic one are different findings | -| `verifier_code_identity` | string | no | Digest of the verifier's own implementation. Self-asserted and of no weight singly; a correlation key across results, since two verifiers at different implementations disagreeing over one closure is verifier drift | +| `verifier_code_identity` | string | no | `sha256:` or `sha384:` digest of the verifier's own implementation. Self-asserted and of no weight singly; a correlation key across results, since two verifiers at different implementations disagreeing over one closure is verifier drift | diff --git a/tests/test_schema_doc_coverage.py b/tests/test_schema_doc_coverage.py index 26311398..17b6f842 100644 --- a/tests/test_schema_doc_coverage.py +++ b/tests/test_schema_doc_coverage.py @@ -31,10 +31,17 @@ from its own row while it survives in another. Each mutation reds the guard and names exactly the member it removed. +One thing a description says *is* decided here, and the boundary is worth stating +exactly. Where a field's ``pattern`` admits more than one digest algorithm, the row +has to name each one. That is not a judgement about whether prose agrees with the +spec; it is a comparison against the field's own machine-readable constraint, which +is why it can be checked at all and why the failure names the field and the missing +algorithm rather than asking a reader to adjudicate wording. + What this file does not decide, and is not scoped to: whether the description -attached to a field agrees with the spec, or whether an example is correct. -Neither is mechanically decidable, and a guard that reaches past what it can -decide produces failures nobody can act on. The wider corpus under ``docs/`` is +attached to a field agrees with the spec in any wider sense, or whether an example +is correct. Neither is mechanically decidable, and a guard that reaches past what +it can decide produces failures nobody can act on. The wider corpus under ``docs/`` is not consulted: a mention on a tutorial page is not documentation of a field, and a corpus-wide token check is a strict subset of this page-level one, so it could never be the only failure. @@ -170,6 +177,86 @@ def undocumented(schema: dict[str, Any], page: str) -> tuple[list[Field], list[V return missing_fields, missing_values +#: Every digest algorithm the project spells, with the hex length that follows it. +#: A probe value is built from each and offered to the field's own `pattern`, so +#: "admits sha384" is answered by the pattern rather than by reading it: the +#: alternation `^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$` factors the prefix out, so it +#: contains neither `sha256` nor `sha384` as text. +_DIGEST_ALGORITHMS: tuple[tuple[str, int], ...] = ( + ("sha256", 64), + ("sha384", 96), + ("sha512", 128), +) + + +def _subschema(schema: dict[str, Any], path: str, name: str) -> dict[str, Any]: + """The subschema for a field, addressed the way `SECTION_OF_PATH` addresses it. + + This follows `properties` and `items` only, while `_walk` also descends `if`, + `then` and `allOf`. A member declared solely inside a conditional branch is + therefore reachable by `_walk` and not by this, and raises `KeyError` here rather + than being passed over, so the day one appears this fails loudly instead of + exempting the field from the algorithm check without saying so. + """ + node: Any = schema + for part in filter(None, path.split(".")): + array = part.endswith("[]") + node = node["properties"][part[:-2] if array else part] + if array: + node = node["items"] + member: dict[str, Any] = node["properties"][name] + return member + + +def admitted_algorithms(subschema: dict[str, Any]) -> set[str]: + """The digest algorithms a field's `pattern` actually accepts. + + `re.search` rather than `fullmatch`, because an unanchored JSON Schema `pattern` + matches anywhere and these are anchored in the schema itself. + """ + pattern = subschema.get("pattern") + if not isinstance(pattern, str) or "sha" not in pattern: + return set() + matcher = re.compile(pattern) + return { + label for label, width in _DIGEST_ALGORITHMS if matcher.search(f"{label}:{'a' * width}") + } + + +def undocumented_algorithms(schema: dict[str, Any], page: str) -> list[tuple[Field, set[str]]]: + """Fields whose `pattern` admits more than one digest algorithm, paired with the + ones their row does not name. + + A row that documents a field into existence can still misdescribe it, and the + coverage guard above cannot see that: it asks whether a row exists, never what the + row claims. A producer reading `sha256:` where the schema also takes `sha384:` + writes the narrower record, and a verifier author reading it may refuse a valid one. + + The field set comes from `schema_fields_and_values`, not from a second enumeration, + so a field this file cannot reach is equally invisible to both guards rather than to + one of them. Resolving each field's subschema is a separate lookup, `_subschema`, + which reaches less of the schema than that walk does and says so. + """ + fields, _ = schema_fields_and_values(schema) + sections = _sections(page) + tables = {path: _rows(sections.get(heading, "")) for path, heading in SECTION_OF_PATH.items()} + + gaps: list[tuple[Field, set[str]]] = [] + for path, name in sorted(fields): + admitted = admitted_algorithms(_subschema(schema, path, name)) + if len(admitted) < 2: + continue + description = tables.get(path, {}).get(name, "") + spelled = { + label + for label in admitted + if re.search(label.replace("sha", "sha-?"), description, re.IGNORECASE) + } + if spelled != admitted: + gaps.append(((path, name), admitted - spelled)) + return gaps + + @pytest.fixture(scope="module") def schema() -> dict[str, Any]: loaded: dict[str, Any] = json.loads(CANONICAL_SCHEMA_PATH.read_text(encoding="utf-8")) @@ -233,6 +320,23 @@ def test_every_schema_field_and_value_is_documented_in_its_place( assert not missing_fields and not missing_values, _report(missing_fields, missing_values) +def test_every_digest_field_names_each_algorithm_its_pattern_admits( + schema: dict[str, Any], page: str +) -> None: + """A row that names one algorithm where the pattern takes two sends a producer to + the narrower record and a verifier author to the narrower rule.""" + gaps = undocumented_algorithms(schema, page) + assert not gaps, "\n".join( + ["docs/schema.md describes these fields more narrowly than the schema accepts:"] + + [ + f" {path + '.' if path else ''}{name}: the row does not name " + f"{', '.join(sorted(unnamed))}, which the pattern admits" + for (path, name), unnamed in gaps + ] + + ["Name every algorithm the pattern takes, as the other digest rows do."] + ) + + # --------------------------------------------------------------------- it can go red @@ -412,3 +516,46 @@ def test_the_guard_fails_when_a_value_is_removed_from_its_fields_row( missing_fields, missing_values = undocumented(schema, stripped) assert missing_fields == [] assert missing_values == [expected] + + +def test_admitted_algorithms_reads_the_alternation_the_schema_actually_uses() -> None: + """The schema factors the prefix out: `^sha(256:...|384:...)$` contains neither the + literal `sha256` nor `sha384`. A check that searched the pattern text for either + would find no algorithm at all, conclude the field constrains none, and skip it, so + the probe offers the pattern a value and believes the answer. The reading version of + this check was written first and reported no gaps anywhere, which is what this pins.""" + both = r"^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$" + assert admitted_algorithms({"pattern": both}) == {"sha256", "sha384"} + assert admitted_algorithms({"pattern": "^sha256:[0-9a-f]{64}$"}) == {"sha256"} + assert admitted_algorithms({"type": "string"}) == set() + + +def test_the_guard_fails_when_a_row_stops_naming_an_algorithm_its_pattern_admits( + schema: dict[str, Any], page: str +) -> None: + """Narrowing a corrected row back to its previous wording reports exactly that + field, and only it.""" + sections = _sections(page) + row = next( + line for line in sections["## `policy`"].splitlines() if line.startswith("| `bundle_hash` ") + ) + assert page.count(row) == 1 + narrowed = row.replace("`sha256:` or `sha384:`", "`sha256:`") + assert narrowed != row, "the row no longer carries the form this test narrows" + gaps = undocumented_algorithms(schema, page.replace(row, narrowed)) + assert gaps == [(("policy", "bundle_hash"), {"sha384"})] + + +def test_the_guard_ignores_a_field_whose_pattern_admits_one_algorithm( + schema: dict[str, Any], page: str +) -> None: + """The rule is that a row names what its own pattern takes, not that every digest row + lists every algorithm the project spells. A field narrowed to one is not a gap even + though its row still names two.""" + narrowed = copy.deepcopy(schema) + narrowed["properties"]["policy"]["properties"]["bundle_hash"]["pattern"] = ( + "^sha256:[0-9a-f]{64}$" + ) + assert admitted_algorithms(_subschema(narrowed, "policy", "bundle_hash")) == {"sha256"} + gaps = undocumented_algorithms(narrowed, page) + assert not [gap for gap in gaps if gap[0][1] == "bundle_hash"] From b1251d6a294341c1fd63b95ed3dadab7e3658b92 Mon Sep 17 00:00:00 2001 From: opento-suggestions Date: Mon, 12 Oct 2026 15:50:34 -0600 Subject: [PATCH 2/2] docs(schema): name both algorithms on appraisal.platform_measurement.measurement #477 added appraisal.platform_measurement.measurement after this branch was cut. Its pattern is the same ^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$ as the other digest fields, and its row named no algorithm, so merging main reddened the guard this branch adds: the field and both algorithms were named, and the counterfactual test failed because its baseline was no longer clean. The row now takes the form the other corrected rows use. That makes thirteen digest fields, of which seven rows were narrower than the pattern, and the CHANGELOG entry carries those counts. The commit message of 7ca1d25 says twelve and six, which was true of the tree it was cut from. Reverting this row alone reds the guard and names that field alone. sha512: is refused by its pattern, as by the other twelve. Editorial: no schema, wire-format or normative change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: opento-suggestions --- CHANGELOG.md | 27 ++++++++++++++------------- docs/schema.md | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdfc3d4b..d530a22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,21 +156,22 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. Uses an example-local format with a v0.2 record, not a v0.3 implementation. - **`docs/schema.md` names both digest algorithms wherever the schema admits - both.** All twelve digest fields in `schema/trace-claim.json` carry the same - pattern shape and take `sha256:` or `sha384:`. Six of the twelve rows on the - schema reference page described their field more narrowly than that: + both.** All thirteen digest fields in `schema/trace-claim.json` carry the same + pattern shape and take `sha256:` or `sha384:`. Seven of the thirteen rows on + the schema reference page described their field more narrowly than that: `model.weights_digest`, `policy.bundle_hash`, `tool_transcript.hash` and `build_provenance.digest` named `sha256:` alone, and the two - `appraisal.re_execution` digests named no algorithm at all, while the other - six named both: four as `sha256:` or `sha384:`, one as `sha256:`/`sha384:`, - and `runtime.measurement` by naming the platform each belongs to. A producer - reading one of the narrower rows writes the narrower record, and a verifier - author reading one may refuse a record the schema accepts. - `tests/test_schema_doc_coverage.py` now fails when a row does not name every - algorithm its field's pattern admits. It asks the pattern with a probe value - rather than searching its text, because the schema factors the prefix out as - `^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$` and so contains neither name. - Editorial: no schema, wire-format or normative change. + `appraisal.re_execution` digests and + `appraisal.platform_measurement.measurement` named no algorithm at all, while + the other six named both: four as `sha256:` or `sha384:`, one as + `sha256:`/`sha384:`, and `runtime.measurement` by naming the platform each + belongs to. A producer reading one of the narrower rows writes the narrower + record, and a verifier author reading one may refuse a record the schema + accepts. `tests/test_schema_doc_coverage.py` now fails when a row does not + name every algorithm its field's pattern admits. It asks the pattern with a + probe value rather than searching its text, because the schema factors the + prefix out as `^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$` and so contains + neither name. Editorial: no schema, wire-format or normative change. ## [0.11.0] - 2026-09-25 diff --git a/docs/schema.md b/docs/schema.md index f047bb32..12de13ab 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -233,7 +233,7 @@ What a matching `runtime.measurement` covers, layer by layer, attributed to the | Field | Type | Required | Description | |---|---|---|---| -| `measurement` | string | **yes** | The digest this appraisal is about. Must equal `runtime.measurement`: a result about another measurement is not about this record | +| `measurement` | string | **yes** | `sha256:` or `sha384:` digest this appraisal is about. Must equal `runtime.measurement`: a result about another measurement is not about this record | | `layers` | object | **yes** | At least one member, keyed by the platform's name for the layer. On `tpm2` the key is `pcr:` and a register number from 0 to 23 in decimal without leading zeros (`pcr:0`, `pcr:23`). Each member carries `outcome` and, when not established, `reason`. A layer not listed is not established, and a record without this block establishes no layer | Each member of `layers`: