feat(protocol): ADR-0140 CORE Trace Protocol v0 (#259)
* feat(protocol): add protocol package exports * feat(protocol): add canonical serialization and hashing * feat(protocol): add CTP typed records * feat(protocol): add CTP envelope * feat(protocol): add CTP event constructors * feat(protocol): export CTP constructors * feat(protocol): add JSONL event IO * feat(protocol): add CTP replay verification * feat(protocol): export JSONL and replay helpers * test(protocol): pin CTP canonical replay contracts * docs(protocol): ratify CORE Trace Protocol v0
This commit is contained in:
parent
c93c1f6ce7
commit
9b1c94704c
9 changed files with 730 additions and 0 deletions
46
core/protocol/__init__.py
Normal file
46
core/protocol/__init__.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"""CORE Trace Protocol v0 package."""
|
||||
|
||||
from .canonical import canonical_bytes, canonical_hash, canonicalize
|
||||
from .envelope import CtpEnvelope
|
||||
from .events import (
|
||||
evidence_observed,
|
||||
invariant_checked,
|
||||
learning_proposal_created,
|
||||
tool_invocation_completed,
|
||||
tool_invocation_requested,
|
||||
turn_completed,
|
||||
turn_refused,
|
||||
turn_requested,
|
||||
verdict_assigned,
|
||||
)
|
||||
from .jsonl import JsonlEventReader, JsonlEventSink, envelope_from_dict
|
||||
from .replay import ReplayViolation, verify_chain, verify_event
|
||||
from .types import CtpActor, CtpEpistemic, CtpInvariant, CtpPayload, CtpProof, CtpStateRef
|
||||
|
||||
__all__ = [
|
||||
"CtpActor",
|
||||
"CtpEnvelope",
|
||||
"CtpEpistemic",
|
||||
"CtpInvariant",
|
||||
"CtpPayload",
|
||||
"CtpProof",
|
||||
"CtpStateRef",
|
||||
"JsonlEventReader",
|
||||
"JsonlEventSink",
|
||||
"ReplayViolation",
|
||||
"canonical_bytes",
|
||||
"canonical_hash",
|
||||
"canonicalize",
|
||||
"envelope_from_dict",
|
||||
"evidence_observed",
|
||||
"invariant_checked",
|
||||
"learning_proposal_created",
|
||||
"tool_invocation_completed",
|
||||
"tool_invocation_requested",
|
||||
"turn_completed",
|
||||
"turn_refused",
|
||||
"turn_requested",
|
||||
"verdict_assigned",
|
||||
"verify_chain",
|
||||
"verify_event",
|
||||
]
|
||||
76
core/protocol/canonical.py
Normal file
76
core/protocol/canonical.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Canonical serialization for CORE Trace Protocol v0.
|
||||
|
||||
The canonical layer is intentionally small and dependency-free. It rejects
|
||||
non-deterministic numeric values, normalizes ``-0.0`` to ``0.0``, sorts object
|
||||
keys, and emits UTF-8 JSON bytes with compact separators.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any
|
||||
|
||||
_HASH_PREFIX = "sha256:"
|
||||
|
||||
|
||||
class CanonicalizationError(ValueError):
|
||||
"""Raised when a value cannot be represented canonically."""
|
||||
|
||||
|
||||
def _canonical_float(value: float) -> float:
|
||||
if not math.isfinite(value):
|
||||
raise CanonicalizationError("CTP canonical JSON forbids NaN and Infinity")
|
||||
if value == 0.0:
|
||||
return 0.0
|
||||
return float(value)
|
||||
|
||||
|
||||
def canonicalize(value: Any) -> Any:
|
||||
"""Return a JSON-compatible canonical structure.
|
||||
|
||||
Supported leaves are ``None``, ``bool``, ``str``, ``int`` and finite
|
||||
``float``. Mappings are sorted during serialization; values are
|
||||
recursively canonicalized. Dataclasses are converted through
|
||||
``dataclasses.asdict``.
|
||||
"""
|
||||
if is_dataclass(value):
|
||||
return canonicalize(asdict(value))
|
||||
if value is None or isinstance(value, (bool, str)):
|
||||
return value
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return _canonical_float(value)
|
||||
if isinstance(value, Mapping):
|
||||
out: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise CanonicalizationError("CTP object keys must be strings")
|
||||
out[key] = canonicalize(item)
|
||||
return out
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
return [canonicalize(item) for item in value]
|
||||
raise CanonicalizationError(f"Unsupported CTP value type: {type(value).__name__}")
|
||||
|
||||
|
||||
def canonical_bytes(value: Any) -> bytes:
|
||||
"""Serialize *value* to canonical UTF-8 JSON bytes."""
|
||||
normalized = canonicalize(value)
|
||||
encoded = json.dumps(
|
||||
normalized,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return encoded.encode("utf-8")
|
||||
|
||||
|
||||
def canonical_hash(value: Any) -> str:
|
||||
"""Return the SHA-256 content address for *value*."""
|
||||
digest = hashlib.sha256(canonical_bytes(value)).hexdigest()
|
||||
return f"{_HASH_PREFIX}{digest}"
|
||||
92
core/protocol/envelope.py
Normal file
92
core/protocol/envelope.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .canonical import canonical_hash, canonicalize
|
||||
from .types import CtpActor, CtpEpistemic, CtpPayload, CtpProof, CtpStateRef
|
||||
|
||||
_ALLOWED_KINDS = {"command", "event", "observation", "proposal", "verdict", "proof", "snapshot"}
|
||||
_ALLOWED_PAYLOAD_ENCODINGS = {"json.v1", "vmp.binary.v1", "opaque.ref.v1"}
|
||||
_REQUIRED_TURN_FIELDS = {"core.turn.completed.v1", "core.turn.refused.v1"}
|
||||
|
||||
|
||||
def _payload_canonical(payload: CtpPayload) -> dict:
|
||||
data = {
|
||||
"encoding": payload.encoding,
|
||||
"schema": payload.schema,
|
||||
"body": payload.body,
|
||||
"body_ref": payload.body_ref,
|
||||
}
|
||||
data["hash"] = payload.hash or canonical_hash(data)
|
||||
return canonicalize(data)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtpEnvelope:
|
||||
ctp_version: str
|
||||
message_type: str
|
||||
kind: str
|
||||
actor: CtpActor
|
||||
payload: CtpPayload
|
||||
causation_id: str = ""
|
||||
correlation_id: str = ""
|
||||
sequence: int = 0
|
||||
state: CtpStateRef = field(default_factory=CtpStateRef)
|
||||
epistemic: CtpEpistemic | None = None
|
||||
proof: CtpProof = field(default_factory=CtpProof)
|
||||
message_id: str = ""
|
||||
|
||||
def canonical(self, *, include_message_id: bool = True) -> dict:
|
||||
data = {
|
||||
"ctp_version": self.ctp_version,
|
||||
"message_type": self.message_type,
|
||||
"kind": self.kind,
|
||||
"causation_id": self.causation_id,
|
||||
"correlation_id": self.correlation_id,
|
||||
"sequence": self.sequence,
|
||||
"actor": self.actor,
|
||||
"state": self.state,
|
||||
"epistemic": self.epistemic,
|
||||
"proof": self.proof,
|
||||
"payload": _payload_canonical(self.payload),
|
||||
}
|
||||
if include_message_id:
|
||||
data["message_id"] = self.message_id or self.computed_message_id()
|
||||
return canonicalize(data)
|
||||
|
||||
def computed_message_id(self) -> str:
|
||||
return canonical_hash(self.canonical(include_message_id=False))
|
||||
|
||||
def with_computed_message_id(self) -> "CtpEnvelope":
|
||||
return CtpEnvelope(
|
||||
ctp_version=self.ctp_version,
|
||||
message_type=self.message_type,
|
||||
kind=self.kind,
|
||||
actor=self.actor,
|
||||
payload=self.payload,
|
||||
causation_id=self.causation_id,
|
||||
correlation_id=self.correlation_id,
|
||||
sequence=self.sequence,
|
||||
state=self.state,
|
||||
epistemic=self.epistemic,
|
||||
proof=self.proof,
|
||||
message_id=self.computed_message_id(),
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.ctp_version != "0.1":
|
||||
raise ValueError(f"Unsupported CTP version: {self.ctp_version}")
|
||||
if self.kind not in _ALLOWED_KINDS:
|
||||
raise ValueError(f"Unsupported CTP kind: {self.kind}")
|
||||
if self.payload.encoding not in _ALLOWED_PAYLOAD_ENCODINGS:
|
||||
raise ValueError(f"Unsupported CTP payload encoding: {self.payload.encoding}")
|
||||
if self.sequence < 0:
|
||||
raise ValueError("CTP sequence must be non-negative")
|
||||
if self.message_id and self.message_id != self.computed_message_id():
|
||||
raise ValueError("CTP message_id does not match canonical content")
|
||||
if self.message_type in _REQUIRED_TURN_FIELDS:
|
||||
if self.epistemic is None:
|
||||
raise ValueError(f"{self.message_type} requires epistemic metadata")
|
||||
if not self.proof.trace_hash:
|
||||
raise ValueError(f"{self.message_type} requires proof.trace_hash")
|
||||
self.canonical()
|
||||
178
core/protocol/events.py
Normal file
178
core/protocol/events.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .envelope import CtpEnvelope
|
||||
from .types import CtpActor, CtpEpistemic, CtpInvariant, CtpPayload, CtpProof, CtpStateRef
|
||||
|
||||
_DEFAULT_ACTOR = CtpActor(kind="core_runtime", id="core.protocol", authority="system")
|
||||
|
||||
|
||||
def _env(
|
||||
*,
|
||||
message_type: str,
|
||||
kind: str,
|
||||
payload_schema: str,
|
||||
payload_body: dict[str, Any],
|
||||
actor: CtpActor = _DEFAULT_ACTOR,
|
||||
causation_id: str = "",
|
||||
correlation_id: str = "",
|
||||
sequence: int = 0,
|
||||
state: CtpStateRef | None = None,
|
||||
epistemic: CtpEpistemic | None = None,
|
||||
proof: CtpProof | None = None,
|
||||
) -> CtpEnvelope:
|
||||
event = CtpEnvelope(
|
||||
ctp_version="0.1",
|
||||
message_type=message_type,
|
||||
kind=kind,
|
||||
actor=actor,
|
||||
causation_id=causation_id,
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
state=state or CtpStateRef(),
|
||||
epistemic=epistemic,
|
||||
proof=proof or CtpProof(),
|
||||
payload=CtpPayload(
|
||||
encoding="json.v1",
|
||||
schema=payload_schema,
|
||||
body=payload_body,
|
||||
),
|
||||
).with_computed_message_id()
|
||||
event.validate()
|
||||
return event
|
||||
|
||||
|
||||
def turn_requested(input_text: str, *, correlation_id: str, sequence: int = 0) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.turn.requested.v1",
|
||||
kind="command",
|
||||
payload_schema="core.turn.requested.payload.v1",
|
||||
payload_body={"input_text": input_text},
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def turn_completed(
|
||||
*,
|
||||
surface: str,
|
||||
trace_hash: str,
|
||||
epistemic: CtpEpistemic,
|
||||
causation_id: str,
|
||||
correlation_id: str,
|
||||
sequence: int,
|
||||
state: CtpStateRef | None = None,
|
||||
proof: CtpProof | None = None,
|
||||
) -> CtpEnvelope:
|
||||
p = proof or CtpProof()
|
||||
p = CtpProof(
|
||||
trace_hash=trace_hash,
|
||||
replay_digest=p.replay_digest,
|
||||
admissibility_trace_hash=p.admissibility_trace_hash,
|
||||
operator_invocation=p.operator_invocation,
|
||||
versor_condition=p.versor_condition,
|
||||
refusal_reason=p.refusal_reason,
|
||||
invariants=p.invariants,
|
||||
)
|
||||
return _env(
|
||||
message_type="core.turn.completed.v1",
|
||||
kind="event",
|
||||
payload_schema="core.turn.completed.payload.v1",
|
||||
payload_body={"surface": surface},
|
||||
causation_id=causation_id,
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
state=state,
|
||||
epistemic=epistemic,
|
||||
proof=p,
|
||||
)
|
||||
|
||||
|
||||
def turn_refused(
|
||||
*,
|
||||
refusal_reason: str,
|
||||
trace_hash: str,
|
||||
epistemic: CtpEpistemic,
|
||||
causation_id: str,
|
||||
correlation_id: str,
|
||||
sequence: int,
|
||||
) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.turn.refused.v1",
|
||||
kind="event",
|
||||
payload_schema="core.turn.refused.payload.v1",
|
||||
payload_body={"refusal_reason": refusal_reason},
|
||||
causation_id=causation_id,
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
epistemic=epistemic,
|
||||
proof=CtpProof(trace_hash=trace_hash, refusal_reason=refusal_reason),
|
||||
)
|
||||
|
||||
|
||||
def evidence_observed(source: str, ref: str, *, correlation_id: str, sequence: int) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.evidence.observed.v1",
|
||||
kind="observation",
|
||||
payload_schema="core.evidence.observed.payload.v1",
|
||||
payload_body={"source": source, "ref": ref},
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def tool_invocation_requested(tool_name: str, args_hash: str, *, correlation_id: str, sequence: int) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.tool.invocation.requested.v1",
|
||||
kind="command",
|
||||
payload_schema="core.tool.invocation.requested.payload.v1",
|
||||
payload_body={"tool_name": tool_name, "args_hash": args_hash},
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def tool_invocation_completed(tool_name: str, result_hash: str, *, causation_id: str, correlation_id: str, sequence: int) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.tool.invocation.completed.v1",
|
||||
kind="event",
|
||||
payload_schema="core.tool.invocation.completed.payload.v1",
|
||||
payload_body={"tool_name": tool_name, "result_hash": result_hash},
|
||||
causation_id=causation_id,
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def learning_proposal_created(proposal_id: str, *, correlation_id: str, sequence: int) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.learning.proposal.created.v1",
|
||||
kind="proposal",
|
||||
payload_schema="core.learning.proposal.created.payload.v1",
|
||||
payload_body={"proposal_id": proposal_id},
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def verdict_assigned(subject_message_id: str, verdict: str, *, correlation_id: str, sequence: int) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.verdict.assigned.v1",
|
||||
kind="verdict",
|
||||
payload_schema="core.verdict.assigned.payload.v1",
|
||||
payload_body={"subject_message_id": subject_message_id, "verdict": verdict},
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
|
||||
|
||||
def invariant_checked(invariant: CtpInvariant, *, correlation_id: str, sequence: int) -> CtpEnvelope:
|
||||
return _env(
|
||||
message_type="core.proof.invariant.checked.v1",
|
||||
kind="proof",
|
||||
payload_schema="core.proof.invariant.checked.payload.v1",
|
||||
payload_body={"invariant": invariant},
|
||||
correlation_id=correlation_id,
|
||||
sequence=sequence,
|
||||
)
|
||||
69
core/protocol/jsonl.py
Normal file
69
core/protocol/jsonl.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
from .envelope import CtpEnvelope
|
||||
from .types import CtpActor, CtpEpistemic, CtpInvariant, CtpPayload, CtpProof, CtpStateRef
|
||||
|
||||
|
||||
def _invariant_from_dict(data: dict) -> CtpInvariant:
|
||||
return CtpInvariant(**data)
|
||||
|
||||
|
||||
def envelope_from_dict(data: dict) -> CtpEnvelope:
|
||||
proof = data.get("proof") or {}
|
||||
invariants = tuple(_invariant_from_dict(i) for i in proof.get("invariants", ()))
|
||||
env = CtpEnvelope(
|
||||
ctp_version=data["ctp_version"],
|
||||
message_type=data["message_type"],
|
||||
kind=data["kind"],
|
||||
actor=CtpActor(**data["actor"]),
|
||||
payload=CtpPayload(**data["payload"]),
|
||||
causation_id=data.get("causation_id", ""),
|
||||
correlation_id=data.get("correlation_id", ""),
|
||||
sequence=int(data.get("sequence", 0)),
|
||||
state=CtpStateRef(**(data.get("state") or {})),
|
||||
epistemic=(CtpEpistemic(**data["epistemic"]) if data.get("epistemic") else None),
|
||||
proof=CtpProof(
|
||||
trace_hash=proof.get("trace_hash", ""),
|
||||
replay_digest=proof.get("replay_digest", ""),
|
||||
admissibility_trace_hash=proof.get("admissibility_trace_hash", ""),
|
||||
operator_invocation=proof.get("operator_invocation", ""),
|
||||
versor_condition=proof.get("versor_condition"),
|
||||
refusal_reason=proof.get("refusal_reason", ""),
|
||||
invariants=invariants,
|
||||
),
|
||||
message_id=data.get("message_id", ""),
|
||||
)
|
||||
env.validate()
|
||||
return env
|
||||
|
||||
|
||||
class JsonlEventSink:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
|
||||
def append(self, event: CtpEnvelope) -> None:
|
||||
event.validate()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(event.canonical(), ensure_ascii=False, sort_keys=True))
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
class JsonlEventReader:
|
||||
def __init__(self, path: str | Path):
|
||||
self.path = Path(path)
|
||||
|
||||
def __iter__(self) -> Iterator[CtpEnvelope]:
|
||||
with self.path.open("r", encoding="utf-8") as fh:
|
||||
for line_no, line in enumerate(fh, start=1):
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
try:
|
||||
yield envelope_from_dict(json.loads(stripped))
|
||||
except Exception as exc: # pragma: no cover - preserves line context
|
||||
raise ValueError(f"Invalid CTP JSONL event at line {line_no}: {exc}") from exc
|
||||
31
core/protocol/replay.py
Normal file
31
core/protocol/replay.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from .envelope import CtpEnvelope
|
||||
|
||||
|
||||
class ReplayViolation(ValueError):
|
||||
"""Raised when a CTP event or chain cannot be replay-verified."""
|
||||
|
||||
|
||||
def verify_event(event: CtpEnvelope) -> None:
|
||||
try:
|
||||
event.validate()
|
||||
except ValueError as exc:
|
||||
raise ReplayViolation(str(exc)) from exc
|
||||
|
||||
|
||||
def verify_chain(events: Sequence[CtpEnvelope]) -> None:
|
||||
previous_id = ""
|
||||
previous_sequence = -1
|
||||
for idx, event in enumerate(events):
|
||||
verify_event(event)
|
||||
if event.sequence < previous_sequence:
|
||||
raise ReplayViolation("CTP event sequence regressed")
|
||||
if idx > 0 and event.causation_id and event.causation_id != previous_id:
|
||||
raise ReplayViolation(
|
||||
f"CTP causation break at index {idx}: expected {previous_id}, got {event.causation_id}"
|
||||
)
|
||||
previous_id = event.message_id or event.computed_message_id()
|
||||
previous_sequence = event.sequence
|
||||
55
core/protocol/types.py
Normal file
55
core/protocol/types.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtpActor:
|
||||
kind: str
|
||||
id: str
|
||||
authority: str = "system"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtpStateRef:
|
||||
runtime_config_hash: str = ""
|
||||
pack_set_hash: str = ""
|
||||
field_state_hash: str = ""
|
||||
backend: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtpEpistemic:
|
||||
state: str
|
||||
grounding_source: str
|
||||
normative_clearance: Literal["CLEARED", "VIOLATED", "UNASSESSABLE", "SUPPRESSED"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtpInvariant:
|
||||
name: str
|
||||
status: Literal["passed", "failed", "unassessed"]
|
||||
value: int | float | str | bool | None = None
|
||||
threshold: int | float | str | bool | None = None
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtpProof:
|
||||
trace_hash: str = ""
|
||||
replay_digest: str = ""
|
||||
admissibility_trace_hash: str = ""
|
||||
operator_invocation: str = ""
|
||||
versor_condition: float | None = None
|
||||
refusal_reason: str = ""
|
||||
invariants: tuple[CtpInvariant, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CtpPayload:
|
||||
encoding: str
|
||||
schema: str
|
||||
body: dict[str, Any] = field(default_factory=dict)
|
||||
body_ref: str = ""
|
||||
hash: str = ""
|
||||
57
docs/decisions/ADR-0140-core-trace-protocol-v0.md
Normal file
57
docs/decisions/ADR-0140-core-trace-protocol-v0.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# ADR-0140: CORE Trace Protocol v0
|
||||
|
||||
Status: Proposed
|
||||
|
||||
## Context
|
||||
|
||||
CORE must not use external tool or agent protocols as its native cognition substrate. MCP, A2A, OpenAPI, and similar interfaces are useful boundary adapters, but they do not carry CORE's internal requirements: deterministic replay, proof binding, epistemic state, normative clearance, invariant status, causation, and algebraic payload references.
|
||||
|
||||
CORE already computes deterministic trace hashes over semantically meaningful turn outputs. ADR-0140 makes that discipline protocol-level: every meaningful command, observation, proposal, verdict, proof, and state transition can be represented as a typed, versioned, canonical event.
|
||||
|
||||
## Decision
|
||||
|
||||
Introduce CORE Trace Protocol (CTP) v0 as the native event envelope for cognition-ledger messages.
|
||||
|
||||
CTP v0 is:
|
||||
|
||||
- typed: every event has a versioned `message_type`
|
||||
- canonical: event bytes are serialized with sorted-key UTF-8 JSON
|
||||
- content-addressed: `message_id` is the SHA-256 hash of canonical content excluding `message_id`
|
||||
- causally linked: events may point to the message that caused them
|
||||
- epistemic-aware: completed/refused turns require epistemic metadata
|
||||
- proof-aware: completed/refused turns require a trace hash
|
||||
- adapter-neutral: external protocols translate into CTP; they do not govern runtime semantics
|
||||
|
||||
Algebraic payloads may later use VMP (`vmp.binary.v1`) inside the CTP `payload` field. VMP is a payload encoding, not the outer protocol.
|
||||
|
||||
## Non-goals
|
||||
|
||||
ADR-0140 does not wire CTP into `ChatRuntime` yet. It does not define the full VMP binary codec. It does not replace existing trace-hash code. It does not expose an MCP server. It creates the deterministic envelope and replayable JSONL spine required before runtime integration.
|
||||
|
||||
## Protocol families
|
||||
|
||||
Initial v0 families:
|
||||
|
||||
- `core.turn.*`
|
||||
- `core.evidence.*`
|
||||
- `core.tool.*`
|
||||
- `core.learning.*`
|
||||
- `core.verdict.*`
|
||||
- `core.proof.*`
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
1. Identical semantic envelopes produce byte-identical canonical JSON.
|
||||
2. Identical semantic envelopes produce identical message IDs.
|
||||
3. Semantic payload changes change the message ID.
|
||||
4. NaN and Infinity are rejected.
|
||||
5. Negative zero canonicalizes to positive zero.
|
||||
6. Completed/refused turns require epistemic metadata.
|
||||
7. Completed/refused turns require `proof.trace_hash`.
|
||||
8. JSONL event logs round-trip into typed envelopes.
|
||||
9. Replay verification detects causation-chain breaks.
|
||||
10. The v0 package is isolated and does not mutate live runtime behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
CTP gives CORE an internal cognition ledger: tool calls become observations, teaching updates become proposals, refusals become verdict-bearing events, and final outputs remain replayable proof-carrying transitions. This keeps MCP and other ecosystem protocols at the perimeter while preserving CORE's own truth, replay, and clearance semantics internally.
|
||||
126
tests/test_core_trace_protocol.py
Normal file
126
tests/test_core_trace_protocol.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from core.protocol import (
|
||||
CtpEpistemic,
|
||||
CtpInvariant,
|
||||
CtpPayload,
|
||||
JsonlEventReader,
|
||||
JsonlEventSink,
|
||||
ReplayViolation,
|
||||
canonical_bytes,
|
||||
canonical_hash,
|
||||
evidence_observed,
|
||||
invariant_checked,
|
||||
turn_completed,
|
||||
turn_requested,
|
||||
verify_chain,
|
||||
)
|
||||
from core.protocol.canonical import CanonicalizationError
|
||||
from core.protocol.envelope import CtpEnvelope
|
||||
from core.protocol.types import CtpActor, CtpProof
|
||||
|
||||
|
||||
def test_canonical_hash_is_order_insensitive_and_rejects_nan():
|
||||
a = {"b": 2, "a": [1, -0.0]}
|
||||
b = {"a": [1, 0.0], "b": 2}
|
||||
assert canonical_bytes(a) == canonical_bytes(b)
|
||||
assert canonical_hash(a) == canonical_hash(b)
|
||||
with pytest.raises(CanonicalizationError):
|
||||
canonical_bytes({"bad": math.nan})
|
||||
with pytest.raises(CanonicalizationError):
|
||||
canonical_bytes({"bad": math.inf})
|
||||
|
||||
|
||||
def test_message_id_is_content_addressed_and_payload_changes_change_it():
|
||||
first = turn_requested("What is alpha?", correlation_id="turn-1", sequence=0)
|
||||
same = turn_requested("What is alpha?", correlation_id="turn-1", sequence=0)
|
||||
changed = turn_requested("What is beta?", correlation_id="turn-1", sequence=0)
|
||||
assert first.message_id == same.message_id
|
||||
assert first.message_id != changed.message_id
|
||||
|
||||
|
||||
def test_completed_turn_requires_epistemic_and_trace_hash():
|
||||
bad = CtpEnvelope(
|
||||
ctp_version="0.1",
|
||||
message_type="core.turn.completed.v1",
|
||||
kind="event",
|
||||
actor=CtpActor(kind="test", id="test"),
|
||||
payload=CtpPayload(encoding="json.v1", schema="x", body={}),
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires epistemic"):
|
||||
bad.validate()
|
||||
|
||||
epistemic = CtpEpistemic(
|
||||
state="GROUNDED",
|
||||
grounding_source="pack",
|
||||
normative_clearance="CLEARED",
|
||||
)
|
||||
bad_trace = CtpEnvelope(
|
||||
ctp_version="0.1",
|
||||
message_type="core.turn.completed.v1",
|
||||
kind="event",
|
||||
actor=CtpActor(kind="test", id="test"),
|
||||
payload=CtpPayload(encoding="json.v1", schema="x", body={}),
|
||||
epistemic=epistemic,
|
||||
)
|
||||
with pytest.raises(ValueError, match="requires proof.trace_hash"):
|
||||
bad_trace.validate()
|
||||
|
||||
|
||||
def test_jsonl_round_trip_and_replay_chain(tmp_path):
|
||||
start = turn_requested("What does alpha cause?", correlation_id="turn-2", sequence=0)
|
||||
epistemic = CtpEpistemic(
|
||||
state="GROUNDED",
|
||||
grounding_source="pack",
|
||||
normative_clearance="CLEARED",
|
||||
)
|
||||
done = turn_completed(
|
||||
surface="alpha causes beta.",
|
||||
trace_hash="sha256:trace",
|
||||
epistemic=epistemic,
|
||||
causation_id=start.message_id,
|
||||
correlation_id="turn-2",
|
||||
sequence=1,
|
||||
proof=CtpProof(
|
||||
replay_digest="sha256:replay",
|
||||
versor_condition=0.0,
|
||||
invariants=(CtpInvariant(name="versor_condition", status="passed", value=0.0, threshold=1e-6),),
|
||||
),
|
||||
)
|
||||
path = tmp_path / "events.jsonl"
|
||||
sink = JsonlEventSink(path)
|
||||
sink.append(start)
|
||||
sink.append(done)
|
||||
|
||||
loaded = list(JsonlEventReader(path))
|
||||
assert [e.message_id for e in loaded] == [start.message_id, done.message_id]
|
||||
verify_chain(loaded)
|
||||
|
||||
|
||||
def test_replay_chain_detects_causation_break():
|
||||
first = evidence_observed("fixture", "1", correlation_id="turn-3", sequence=0)
|
||||
second = invariant_checked(
|
||||
CtpInvariant(name="x", status="passed"),
|
||||
correlation_id="turn-3",
|
||||
sequence=1,
|
||||
)
|
||||
broken = second.with_computed_message_id()
|
||||
broken = CtpEnvelope(
|
||||
ctp_version=broken.ctp_version,
|
||||
message_type=broken.message_type,
|
||||
kind=broken.kind,
|
||||
actor=broken.actor,
|
||||
payload=broken.payload,
|
||||
causation_id="sha256:not-the-first-id",
|
||||
correlation_id=broken.correlation_id,
|
||||
sequence=broken.sequence,
|
||||
state=broken.state,
|
||||
epistemic=broken.epistemic,
|
||||
proof=broken.proof,
|
||||
).with_computed_message_id()
|
||||
with pytest.raises(ReplayViolation, match="causation break"):
|
||||
verify_chain([first, broken])
|
||||
Loading…
Reference in a new issue