diff --git a/docs/technical-reference.md b/docs/technical-reference.md index 1ba4eea..306da54 100644 --- a/docs/technical-reference.md +++ b/docs/technical-reference.md @@ -364,6 +364,10 @@ the humans or schedulers around the protocol. - published turns are immutable; any byte change flips the dialogue to `BLOCKED` before the next completion or decision; - missing, malformed, or mismatched runtime evidence blocks completion; +- a turn `evidence_version` outside the definition's declared + `evidence_versions` set blocks completion even when every other field + is perfect; versions the engine cannot interpret are refused at + definition load, even when a definition lists them; - failed/timeout terminal outcomes block completion; - a missing, unknown, or laundered `completed_via` is a structural Git-provenance error; only `run --launch` can record diff --git a/schemas/protocol.schema.json b/schemas/protocol.schema.json index 02f3b77..cc96da3 100644 --- a/schemas/protocol.schema.json +++ b/schemas/protocol.schema.json @@ -32,6 +32,13 @@ "items": {"type": "string", "minLength": 1}, "description": "Optional external owner-proof verifier command ({decision_file} is substituted). Without it, owner-decide records caller_identity as 'unverified': nothing authenticates WHO invoked the decision." }, + "evidence_versions": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"type": "integer", "minimum": 1}, + "description": "Closed set of evidence-record versions this dialogue accepts (default: the engine-supported set). A turn whose evidence_version is outside this set fails closed; versions the engine cannot interpret are rejected at definition load even if listed." + }, "actors": { "type": "array", "minItems": 2, diff --git a/src/multi_agent_dialogue/adapters/base.py b/src/multi_agent_dialogue/adapters/base.py index a7a7d3b..ebe0f6c 100644 --- a/src/multi_agent_dialogue/adapters/base.py +++ b/src/multi_agent_dialogue/adapters/base.py @@ -24,8 +24,7 @@ from .. import artifacts from ..config import Actor, TurnSpec - -EVIDENCE_VERSION = 1 +from ..evidence import EVIDENCE_VERSION class AdapterError(RuntimeError): diff --git a/src/multi_agent_dialogue/config.py b/src/multi_agent_dialogue/config.py index ae4e332..8f5869e 100644 --- a/src/multi_agent_dialogue/config.py +++ b/src/multi_agent_dialogue/config.py @@ -16,6 +16,12 @@ KNOWN_TRANSPORTS = ("command", "fable-session", "hermes-cli") +# Evidence-record versions this engine can interpret. A dialogue +# definition binds to a subset via `evidence_versions`; a version the +# engine cannot interpret must never be accepted, even if a definition +# names it. The latest entry is the version adapters write per turn. +SUPPORTED_EVIDENCE_VERSIONS = (1,) + DEFAULT_OWNER_DECISIONS = ("APPROVE", "REJECT", "NEED_MORE_EVIDENCE") _ACTOR_KEYS = { @@ -63,6 +69,10 @@ class ProtocolDefinition: source_sha: str evidence_roots: tuple[str, ...] owner_decisions: tuple[str, ...] + # Closed set of evidence-record versions this dialogue accepts, + # guaranteed ⊆ SUPPORTED_EVIDENCE_VERSIONS at parse time. A turn whose + # evidence_version is outside this set fails closed. + evidence_versions: tuple[int, ...] = SUPPORTED_EVIDENCE_VERSIONS # Optional external owner-proof verifier command. When empty, the # engine records the owner decision as caller-identity "unverified": # nothing authenticates WHO invoked owner-decide. @@ -217,6 +227,35 @@ def parse_definition(raw: Any) -> ProtocolDefinition: errors.append("definition: owner_decisions must be a non-empty list of strings") decisions_raw = list(DEFAULT_OWNER_DECISIONS) + versions_raw = raw.get("evidence_versions", list(SUPPORTED_EVIDENCE_VERSIONS)) + if ( + not isinstance(versions_raw, list) + or not versions_raw + or not all( + isinstance(item, int) and not isinstance(item, bool) and item >= 1 + for item in versions_raw + ) + ): + errors.append( + "definition: evidence_versions must be a non-empty list of " + "positive integers (the closed set of evidence-record versions " + "this dialogue accepts)" + ) + versions_raw = list(SUPPORTED_EVIDENCE_VERSIONS) + else: + unsupported = sorted( + {item for item in versions_raw if item not in SUPPORTED_EVIDENCE_VERSIONS} + ) + if unsupported: + errors.append( + f"definition: evidence_versions {unsupported} are not " + f"interpretable by this engine (supported: " + f"{list(SUPPORTED_EVIDENCE_VERSIONS)}); a version the engine " + "cannot read must never be accepted" + ) + if len(set(versions_raw)) != len(versions_raw): + errors.append("definition: evidence_versions contains duplicates") + actors_raw = raw.get("actors") actors: list[Actor] = [] if not isinstance(actors_raw, list): @@ -287,6 +326,7 @@ def parse_definition(raw: Any) -> ProtocolDefinition: source_sha=source_sha, evidence_roots=tuple(evidence_roots_raw), owner_decisions=tuple(decisions_raw), + evidence_versions=tuple(versions_raw), owner_proof_argv=tuple(owner_proof_raw), # Snapshot so later caller mutations cannot change the digest. raw=json.loads(canonical_json(raw)), diff --git a/src/multi_agent_dialogue/engine.py b/src/multi_agent_dialogue/engine.py index 7f0f9b9..2ca0ab8 100644 --- a/src/multi_agent_dialogue/engine.py +++ b/src/multi_agent_dialogue/engine.py @@ -445,7 +445,8 @@ def complete( actor = definition.actor(actor_id) evidence_errors = evidence.validate_evidence( - record, actor=actor, turn=turn, artifact_sha256=artifact_sha + record, actor=actor, turn=turn, artifact_sha256=artifact_sha, + accepted_versions=definition.evidence_versions, ) if evidence_errors: raise ProtocolError( @@ -720,6 +721,7 @@ def validate( actor=actor, turn=spec, artifact_sha256=artifact_sha, + accepted_versions=definition.evidence_versions, ) ) except (evidence.EvidenceError, config.ConfigError) as exc: diff --git a/src/multi_agent_dialogue/evidence.py b/src/multi_agent_dialogue/evidence.py index b73a1d5..e21bb02 100644 --- a/src/multi_agent_dialogue/evidence.py +++ b/src/multi_agent_dialogue/evidence.py @@ -15,7 +15,7 @@ from pathlib import Path from .artifacts import ArtifactError, read_bytes_nofollow, sha256_file -from .config import Actor, TurnSpec +from .config import SUPPORTED_EVIDENCE_VERSIONS, Actor, TurnSpec __all__ = [ "EvidenceError", @@ -25,7 +25,9 @@ "sha256_file", ] -EVIDENCE_VERSION = 1 +# The evidence-record version adapters write per turn: the latest the +# engine supports. The canonical support list lives in config.py. +EVIDENCE_VERSION = max(SUPPORTED_EVIDENCE_VERSIONS) REQUIRED_FIELDS = ( "evidence_version", @@ -78,8 +80,17 @@ def validate_evidence( actor: Actor, turn: TurnSpec, artifact_sha256: str, + accepted_versions: tuple[int, ...] | None = None, ) -> list[str]: - """Return every reason this evidence fails; empty list means valid.""" + """Return every reason this evidence fails; empty list means valid. + + ``accepted_versions`` is the closed set declared by the dialogue + definition (``definition.evidence_versions``); None falls back to the + engine-supported set. A record whose evidence_version sits outside + the set fails closed even when every other field is perfect. + """ + if accepted_versions is None: + accepted_versions = SUPPORTED_EVIDENCE_VERSIONS errors: list[str] = [] if not isinstance(record, dict): return ["evidence must be a JSON object"] @@ -90,10 +101,22 @@ def validate_evidence( if errors: return errors - if record["evidence_version"] != EVIDENCE_VERSION: + version = record["evidence_version"] + if isinstance(version, bool) or not isinstance(version, int): + # 1.0 and True compare equal to 1; only a real integer binds. errors.append( - f"unsupported evidence_version: {record['evidence_version']!r}" + f"evidence_version must be an integer, got {version!r}" ) + elif version not in accepted_versions: + errors.append( + f"evidence_version {version!r} is not accepted by this " + f"dialogue definition (accepted: {sorted(accepted_versions)})" + ) + elif version not in SUPPORTED_EVIDENCE_VERSIONS: + # Defense in depth: even a caller that hand-passes a wider + # accepted set cannot make the engine read a record layout it + # does not understand. + errors.append(f"unsupported evidence_version: {version!r}") if record["actor_id"] != actor.actor_id: errors.append( f"evidence actor_id {record['actor_id']!r} does not match " diff --git a/tests/test_config.py b/tests/test_config.py index f83d437..dbd1d8f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -190,5 +190,53 @@ def test_schema_examples_pass_python_validator(self) -> None: self.assertGreaterEqual(len(definition.actors), 2) +class EvidenceVersionsDefinitionTests(unittest.TestCase): + """The definition pins the closed set of evidence-record versions it + accepts; versions the engine cannot interpret are rejected at load + even when a definition names them.""" + + def test_default_binds_to_engine_supported_set(self) -> None: + definition = config.parse_definition(support.two_actor_definition()) + self.assertEqual( + definition.evidence_versions, config.SUPPORTED_EVIDENCE_VERSIONS + ) + + def test_explicit_supported_version_accepted(self) -> None: + raw = support.two_actor_definition() + raw["evidence_versions"] = [1] + definition = config.parse_definition(raw) + self.assertEqual(definition.evidence_versions, (1,)) + + def test_engine_unknown_version_rejected(self) -> None: + raw = support.two_actor_definition() + raw["evidence_versions"] = [1, 2] + with self.assertRaisesRegex(config.ConfigError, "not interpretable"): + config.parse_definition(raw) + + def test_empty_list_rejected(self) -> None: + raw = support.two_actor_definition() + raw["evidence_versions"] = [] + with self.assertRaisesRegex(config.ConfigError, "evidence_versions"): + config.parse_definition(raw) + + def test_non_integer_rejected(self) -> None: + raw = support.two_actor_definition() + raw["evidence_versions"] = ["1"] + with self.assertRaisesRegex(config.ConfigError, "evidence_versions"): + config.parse_definition(raw) + + def test_boolean_is_not_an_integer_here(self) -> None: + raw = support.two_actor_definition() + raw["evidence_versions"] = [True] + with self.assertRaisesRegex(config.ConfigError, "evidence_versions"): + config.parse_definition(raw) + + def test_duplicates_rejected(self) -> None: + raw = support.two_actor_definition() + raw["evidence_versions"] = [1, 1] + with self.assertRaisesRegex(config.ConfigError, "duplicates"): + config.parse_definition(raw) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_evidence.py b/tests/test_evidence.py index 71c62b0..34add74 100644 --- a/tests/test_evidence.py +++ b/tests/test_evidence.py @@ -93,6 +93,26 @@ def test_empty_proof_rejected(self) -> None: errors = self.check(self.valid_evidence(proof={})) self.assertTrue(any("proof" in e for e in errors)) + def test_evidence_version_outside_accepted_set_rejected(self) -> None: + errors = self.check(self.valid_evidence(evidence_version=2)) + self.assertTrue( + any("not accepted by this dialogue definition" in e for e in errors) + ) + + def test_evidence_version_engine_cannot_read_rejected(self) -> None: + # Defense in depth: even a caller hand-passing a wider accepted + # set cannot make the engine read a record layout it does not + # understand. + record = self.valid_evidence(evidence_version=2) + errors = evidence.validate_evidence( + record, + actor=self.actor, + turn=self.turn, + artifact_sha256=evidence.sha256_file(self.artifact), + accepted_versions=(1, 2), + ) + self.assertTrue(any("unsupported evidence_version" in e for e in errors)) + def test_markdown_labels_are_not_evidence(self) -> None: # A record consisting only of frontmatter-style claims must fail. record = {"actor": "worker-a", "model": "fake-model-a", "round": "R01"} @@ -173,6 +193,14 @@ def test_final_completion_is_ready_for_owner(self) -> None: self.assertEqual(state["status"], "READY_FOR_OWNER") self.assertEqual(state["turn_index"], 4) + def test_evidence_version_outside_definition_set_fails_closed(self) -> None: + # Canary from the design review: evidence_version 2 against a + # definition pinned to 1 is rejected and the turn never commits. + with self.assertRaises(engine.ProtocolError) as ctx: + self.complete_turn("worker-a", "R01", evidence_version=2) + self.assertIn("evidence_version", str(ctx.exception)) + self.assertEqual(self.dialogue.state()["turn_index"], 0) + def test_complete_requires_claim(self) -> None: turn_path = self.write_turn("R01") provider, model = self.provider_model("worker-a")