Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/technical-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions schemas/protocol.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Comment thread
askclaw-vesper marked this conversation as resolved.
"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,
Expand Down
3 changes: 1 addition & 2 deletions src/multi_agent_dialogue/adapters/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@

from .. import artifacts
from ..config import Actor, TurnSpec

EVIDENCE_VERSION = 1
from ..evidence import EVIDENCE_VERSION


class AdapterError(RuntimeError):
Expand Down
40 changes: 40 additions & 0 deletions src/multi_agent_dialogue/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)),
Expand Down
4 changes: 3 additions & 1 deletion src/multi_agent_dialogue/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 28 additions & 5 deletions src/multi_agent_dialogue/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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"]
Expand All @@ -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 "
Expand Down
48 changes: 48 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
28 changes: 28 additions & 0 deletions tests/test_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -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")
Expand Down