diff --git a/CHANGELOG.md b/CHANGELOG.md index eb5bd7b..ff5395f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,21 @@ Format: [Semantic Versioning](https://semver.org). Schema versions and record se new record. ### Added +- Optional, append-only `revisions` array on records (#317, design agreed + on #219). Each entry carries `date`, `field`, `from`, `to` and a closed + `reason_code`, with an optional free-text `reason`; `from` is null for an + added field. The four `reason_code` values are copied from + `revision_reason` in probityai/agent-evidence-vocabulary at commit + aff6e0187d05fbb01aa26bd4b4035aebcbc81be0, and the enum description cites + that commit; AVE's copy stands on its own. A record with no `revisions` + array makes no claim about its own history. `validate_records.py` also + refuses entries out of date order and a `last_updated` older than the + latest revision. First use: AVE-2026-00074, whose `evidence_vantage` + stamp in #299 moved its derived `verification_basis` from + `artifact_reconstructed` to `substrate_reconstructed`. Its `last_updated` + moves to the date of this edit, since #299 left it at 2026-08-07. The + other eight records stamped in #299 get no entry, because their derived + basis did not change. - AVE-2026-00087: Agentjacking — a monitoring/diagnostic integration's own publicly-writable ingest endpoint lets an attacker plant a crafted event formatted to visually mimic the integration's diff --git a/docs/guides/confidence-baseline-consumer-guide.md b/docs/guides/confidence-baseline-consumer-guide.md index 5ab692a..0fc1657 100644 --- a/docs/guides/confidence-baseline-consumer-guide.md +++ b/docs/guides/confidence-baseline-consumer-guide.md @@ -42,6 +42,12 @@ a consumer is told which: A record can carry both, one, or neither. +An `evidence_vantage` of `substrate` is the producer's declaration, not an +observation a consumer can check: AVE records are unsigned, so a reader with +no pinned substrate root treats it as unattested. AVE-2026-00074's +`substrate`, for example, rests on queries to external authorities rather +than on observation of an executed artifact. + These were one check until the two predicates were run against each other over the same field. Engine-set cardinality was the check's original floor test, chosen when no measure of vantage existed, and it turns out not to diff --git a/docs/terms.md b/docs/terms.md index 8ba23ca..13e56c6 100644 --- a/docs/terms.md +++ b/docs/terms.md @@ -246,6 +246,10 @@ Name of the researcher or team who authored this record. REQUIRED once a record URL for the researcher or team. Optional. +### `revisions` + +Optional, append-only history of edits that changed a value a consumer may gate on, such as a derived verification_basis. Entries are appended in date order and never rewritten or removed. A record with no revisions array makes no claim about its own history; a consumer must not read its absence as never revised. Design: issue #317, agreed on #219. + ### `schema_version` AVE schema version this record was authored against, e.g. 1.1.0. diff --git a/records/AVE-2026-00074.json b/records/AVE-2026-00074.json index ed515e0..5c7898e 100644 --- a/records/AVE-2026-00074.json +++ b/records/AVE-2026-00074.json @@ -61,7 +61,7 @@ "researcher": "AIR Security", "researcher_url": "https://www.air.security", "published": "2026-08-07T00:00:00Z", - "last_updated": "2026-08-07T00:00:00Z", + "last_updated": "2026-10-13T00:00:00Z", "references": [ { "tag": "repo-forensics dead-anchor scanner", @@ -115,5 +115,15 @@ "version": "2026", "read_date": "2026-08-23" } - } + }, + "revisions": [ + { + "date": "2026-09-28", + "field": "evidence_vantage", + "from": null, + "to": "substrate", + "reason_code": "declaration_added", + "reason": "Stamped in #299. The derived verification_basis moved from artifact_reconstructed to substrate_reconstructed as a result." + } + ] } diff --git a/schema/ave-record-1.1.0.schema.json b/schema/ave-record-1.1.0.schema.json index b842e1f..9c1434d 100644 --- a/schema/ave-record-1.1.0.schema.json +++ b/schema/ave-record-1.1.0.schema.json @@ -644,6 +644,56 @@ } } } + }, + "revisions": { + "type": "array", + "description": "Optional, append-only history of edits that changed a value a consumer may gate on, such as a derived verification_basis. Entries are appended in date order and never rewritten or removed. A record with no revisions array makes no claim about its own history; a consumer must not read its absence as never revised. Design: issue #317, agreed on #219.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "date", + "field", + "from", + "to", + "reason_code" + ], + "properties": { + "date": { + "type": "string", + "format": "date", + "description": "ISO 8601 date the change landed in this record, e.g. the merge date of the pull request that made it." + }, + "field": { + "type": "string", + "minLength": 1, + "description": "Name of the record field whose value changed, e.g. evidence_vantage." + }, + "from": { + "description": "The field's value before the change, or null when the field was absent before (an added declaration has no prior value)." + }, + "to": { + "not": { + "type": "null" + }, + "description": "The field's value after the change." + }, + "reason_code": { + "type": "string", + "enum": [ + "declaration_added", + "declaration_corrected", + "vocabulary_member_added", + "derivation_rule_changed" + ], + "description": "Closed code for why this entry exists, so a consumer can count and filter revisions by cause without parsing prose. Copied from revision_reason in probityai/agent-evidence-vocabulary at commit aff6e0187d05fbb01aa26bd4b4035aebcbc81be0 (https://github.com/probityai/agent-evidence-vocabulary/blob/aff6e0187d05fbb01aa26bd4b4035aebcbc81be0/vocabulary.yaml#L291-L315), where the codes are status proposed; this enum stands on its own and does not follow later changes there. declaration_added: a field the record omitted is now declared, and a value derived from it changed as a result. declaration_corrected: a field the record already declared was wrong and now carries a different value. vocabulary_member_added: the schema gained a closed-vocabulary member, and re-deriving the record under it changed a derived value; the record's own declarations did not change. derivation_rule_changed: the rule that computes a derived value changed; the record's own declarations did not change." + }, + "reason": { + "type": "string", + "description": "Optional free text: what changed downstream and where the change was made." + } + } + } } } } diff --git a/schema/ave-record.schema.json b/schema/ave-record.schema.json index b842e1f..9c1434d 100644 --- a/schema/ave-record.schema.json +++ b/schema/ave-record.schema.json @@ -644,6 +644,56 @@ } } } + }, + "revisions": { + "type": "array", + "description": "Optional, append-only history of edits that changed a value a consumer may gate on, such as a derived verification_basis. Entries are appended in date order and never rewritten or removed. A record with no revisions array makes no claim about its own history; a consumer must not read its absence as never revised. Design: issue #317, agreed on #219.", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "date", + "field", + "from", + "to", + "reason_code" + ], + "properties": { + "date": { + "type": "string", + "format": "date", + "description": "ISO 8601 date the change landed in this record, e.g. the merge date of the pull request that made it." + }, + "field": { + "type": "string", + "minLength": 1, + "description": "Name of the record field whose value changed, e.g. evidence_vantage." + }, + "from": { + "description": "The field's value before the change, or null when the field was absent before (an added declaration has no prior value)." + }, + "to": { + "not": { + "type": "null" + }, + "description": "The field's value after the change." + }, + "reason_code": { + "type": "string", + "enum": [ + "declaration_added", + "declaration_corrected", + "vocabulary_member_added", + "derivation_rule_changed" + ], + "description": "Closed code for why this entry exists, so a consumer can count and filter revisions by cause without parsing prose. Copied from revision_reason in probityai/agent-evidence-vocabulary at commit aff6e0187d05fbb01aa26bd4b4035aebcbc81be0 (https://github.com/probityai/agent-evidence-vocabulary/blob/aff6e0187d05fbb01aa26bd4b4035aebcbc81be0/vocabulary.yaml#L291-L315), where the codes are status proposed; this enum stands on its own and does not follow later changes there. declaration_added: a field the record omitted is now declared, and a value derived from it changed as a result. declaration_corrected: a field the record already declared was wrong and now carries a different value. vocabulary_member_added: the schema gained a closed-vocabulary member, and re-deriving the record under it changed a derived value; the record's own declarations did not change. derivation_rule_changed: the rule that computes a derived value changed; the record's own declarations did not change." + }, + "reason": { + "type": "string", + "description": "Optional free text: what changed downstream and where the change was made." + } + } + } } } } diff --git a/scripts/validate_records.py b/scripts/validate_records.py index 01a47f9..0286d05 100644 --- a/scripts/validate_records.py +++ b/scripts/validate_records.py @@ -168,6 +168,74 @@ def check_no_vendor_boilerplate(raw_text: str) -> list[str]: ] +def check_revision_order(record: dict) -> list[str]: + """The schema checks each revisions entry's shape; it cannot check the + array's order or its agreement with last_updated. Entries are appended in + date order, so a date earlier than the one before it is a rewritten + history, and a revision dated after last_updated means last_updated no + longer names the record's latest change, which is the one signal a + consumer had before this field existed (#317). This checks one record + only: an earlier entry deleted or rewritten in place is visible only + against the previous version of the record, so the schema's append-only + rule is not fully enforced here. + """ + revisions = record.get("revisions") + if not isinstance(revisions, list): + return [] + errors = [] + dates = [entry.get("date", "") for entry in revisions if isinstance(entry, dict)] + for i in range(1, len(dates)): + if dates[i] < dates[i - 1]: + errors.append( + f"revisions[{i}].date {dates[i]} is earlier than revisions[{i - 1}].date " + f"{dates[i - 1]}: revisions are appended in date order" + ) + last_updated = str(record.get("last_updated") or "")[:10] + if dates and last_updated and max(dates) > last_updated: + errors.append( + f"last_updated {last_updated} predates the latest revision {max(dates)}: " + f"last_updated must name the record's latest change" + ) + return errors + + +def check_revision_values(record: dict, known_fields) -> list[str]: + """Each revision must name a field the schema defines and record a real + change, and the revisions of one field must chain: every entry's from is + the previous entry's to, and the latest to is the value the record holds + now. That refuses a misspelt field, a no-op entry, a duplicated entry and + a history that contradicts the record. A deleted or rewritten earlier + entry can only be seen against the previous version of the record. + """ + revisions = record.get("revisions") + if not isinstance(revisions, list): + return [] + errors = [] + latest = {} + for i, entry in enumerate(revisions): + if not isinstance(entry, dict): + continue + field = entry.get("field") + if field not in known_fields: + errors.append(f"revisions[{i}].field {field!r} is not a property the schema defines") + continue + if entry.get("from") == entry.get("to"): + errors.append(f"revisions[{i}] has the same from and to: it records no change") + if field in latest and entry.get("from") != latest[field]: + errors.append( + f"revisions[{i}].from {entry.get('from')!r} is not the previous to " + f"{latest[field]!r} for {field}" + ) + latest[field] = entry.get("to") + for field, value in latest.items(): + if record.get(field) != value: + errors.append( + f"the latest revision of {field} sets it to {value!r}, " + f"but the record holds {record.get(field)!r}" + ) + return errors + + def check_researcher_matches_disclosure(record: dict) -> list[str]: """Soft warning only: flags records where `researcher` is an AVE maintainer name while `references` contains something that reads @@ -198,6 +266,26 @@ def check_researcher_matches_disclosure(record: dict) -> list[str]: return [] +def record_errors(record: dict, raw_text: str, validator) -> list[str]: + """Every hard error for one record, in the order main() reports them. + + main() and the tests both call this, so a check dropped from the + pipeline fails a test instead of passing silently. + """ + return ( + check_schema(record, validator) + + check_no_old_field_names(record) + + check_no_nested_owasp_mcp_mapping(record) + + check_behavioral_vector_or_example_patterns(record) + + check_mitigation_enums_only(record) + + check_aivss_arithmetic(record) + + check_no_vendor_boilerplate(raw_text) + + check_verification_basis(record) + + check_revision_order(record) + + check_revision_values(record, validator.schema.get("properties", {})) + ) + + def main() -> int: schema = json.loads(SCHEMA_PATH.read_text()) jsonschema.Draft202012Validator.check_schema(schema) @@ -213,16 +301,7 @@ def main() -> int: raw_text = path.read_text() record = json.loads(raw_text) rid = record.get("ave_id", path.name) - errors = ( - check_schema(record, validator) - + check_no_old_field_names(record) - + check_no_nested_owasp_mcp_mapping(record) - + check_behavioral_vector_or_example_patterns(record) - + check_mitigation_enums_only(record) - + check_aivss_arithmetic(record) - + check_no_vendor_boilerplate(raw_text) - + check_verification_basis(record) - ) + errors = record_errors(record, raw_text, validator) for e in errors: print(f"{rid}: {e}") total_errors += len(errors) diff --git a/tests/test_revisions.py b/tests/test_revisions.py new file mode 100644 index 0000000..a7568ac --- /dev/null +++ b/tests/test_revisions.py @@ -0,0 +1,143 @@ +import copy +import json + +import pytest + +from scripts import validate_records + + +def _validator(): + schema = json.loads(validate_records.SCHEMA_PATH.read_text(encoding="utf-8")) + return validate_records.build_validator(schema) + + +BASE = { + "ave_id": "AVE-2026-99999", + "schema_version": "1.1.0", + "status": "draft", + "title": "Revisions fixture", + "description": "A minimal draft record carrying a revisions array.", + "attack_class": "test", + "behavioral_fingerprint": "test", + "references": ["https://example.com"], + "last_updated": "2026-09-28T23:51:58Z", + "evidence_vantage": "substrate", + "revisions": [ + { + "date": "2026-09-28", + "field": "evidence_vantage", + "from": None, + "to": "substrate", + "reason_code": "declaration_added", + } + ], +} + + +def _record(**entry_changes): + record = copy.deepcopy(BASE) + record["revisions"][0].update(entry_changes) + return record + + +def test_an_added_field_entry_with_null_from_is_valid(): + assert validate_records.check_schema(_record(), _validator()) == [] + assert validate_records.check_revision_order(_record()) == [] + + +def test_an_unregistered_reason_code_is_refused(): + errors = validate_records.check_schema(_record(reason_code="value_tweaked"), _validator()) + assert any("value_tweaked" in error for error in errors) + + +@pytest.mark.parametrize("missing", ["date", "field", "from", "to", "reason_code"]) +def test_every_required_entry_member_is_required(missing): + record = _record() + del record["revisions"][0][missing] + assert validate_records.check_schema(record, _validator()) + + +def test_a_null_to_is_refused(): + assert validate_records.check_schema(_record(to=None), _validator()) + + +def test_an_unknown_entry_member_is_refused(): + assert validate_records.check_schema(_record(note="x"), _validator()) + + +def test_revisions_out_of_date_order_are_refused(): + record = _record() + record["revisions"].insert(0, dict(record["revisions"][0], date="2026-09-29")) + record["last_updated"] = "2026-09-29T00:00:00Z" + errors = validate_records.check_revision_order(record) + assert any("date order" in error for error in errors) + + +def test_a_revision_after_last_updated_is_refused(): + record = _record() + record["last_updated"] = "2026-08-07T00:00:00Z" + errors = validate_records.check_revision_order(record) + assert any("predates the latest revision" in error for error in errors) + + +def test_a_record_without_revisions_makes_no_claim(): + record = copy.deepcopy(BASE) + del record["revisions"] + assert validate_records.check_revision_order(record) == [] + + +def test_two_revisions_on_the_same_date_are_accepted(): + record = _record(to="artifact") + record["revisions"].append( + { + "date": "2026-09-28", + "field": "evidence_vantage", + "from": "artifact", + "to": "substrate", + "reason_code": "declaration_added", + } + ) + assert validate_records.check_revision_order(record) == [] + + +def test_the_validator_pipeline_refuses_revisions_out_of_date_order(): + record = _record() + record["revisions"].insert(0, dict(record["revisions"][0], date="2026-09-29")) + record["last_updated"] = "2026-09-29T00:00:00Z" + errors = validate_records.record_errors(record, json.dumps(record), _validator()) + assert any("date order" in error for error in errors) + + +KNOWN_FIELDS = json.loads(validate_records.SCHEMA_PATH.read_text(encoding="utf-8"))["properties"] + + +def test_a_consistent_history_passes_the_value_check(): + assert validate_records.check_revision_values(_record(), KNOWN_FIELDS) == [] + + +def test_a_field_the_schema_does_not_define_is_refused(): + errors = validate_records.check_revision_values(_record(field="evidence_vantge"), KNOWN_FIELDS) + assert any("not a property the schema defines" in error for error in errors) + + +def test_a_revision_with_the_same_from_and_to_is_refused(): + errors = validate_records.check_revision_values(_record(**{"from": "substrate"}), KNOWN_FIELDS) + assert any("records no change" in error for error in errors) + + +def test_a_duplicated_revision_breaks_the_chain(): + record = _record() + record["revisions"].append(copy.deepcopy(record["revisions"][0])) + errors = validate_records.check_revision_values(record, KNOWN_FIELDS) + assert any("is not the previous to" in error for error in errors) + + +def test_a_latest_to_that_contradicts_the_record_is_refused(): + errors = validate_records.check_revision_values(_record(to="artifact"), KNOWN_FIELDS) + assert any("but the record holds 'substrate'" in error for error in errors) + + +def test_the_validator_pipeline_refuses_a_contradicting_latest_to(): + record = _record(to="artifact") + errors = validate_records.record_errors(record, json.dumps(record), _validator()) + assert any("but the record holds" in error for error in errors)