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:
Shay 2026-05-25 06:08:51 -07:00 committed by GitHub
parent c93c1f6ce7
commit 9b1c94704c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 730 additions and 0 deletions

46
core/protocol/__init__.py Normal file
View 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",
]

View 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
View 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
View 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
View 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
View 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
View 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 = ""

View 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.

View 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])