Trace/Result Schema & Migration Policy¶
rampart.core.serialization defines RAMPART's canonical, versioned
Result-record format. ResultRecord references a result and optional
pytest_nodeid / result_index attribution.
serialize_record(record=...) converts a ResultRecord to JSON text (str);
deserialize_record(data=...) reconstructs a ResultRecord from JSON text.
Existing xdist and reporting consumers are not yet wired to this module.
This page defines how the schema may evolve as consumers adopt it.
Serialization and schema generation¶
serialize_record() and deserialize_record() are the only public conversion
API. The serialization module owns both the versioned envelope and its body,
using one cached Pydantic TypeAdapter over the existing standard dataclasses.
Neither Result nor ResultRecord exposes dictionary-conversion methods.
ResultRecord references the live result; serialization does not mutate it.
import json
from rampart.core.serialization import (
ResultRecord,
deserialize_record,
serialize_record,
)
record = ResultRecord(result=result, pytest_nodeid="tests/test_safety.py::test_case")
text = serialize_record(record=record)
restored = deserialize_record(data=text)
canonical = json.loads(text)
Projections and structured transports obtain dictionaries by parsing the
canonical JSON, not by maintaining another result serializer or importing private
helpers. Transports that already hold a record dictionary can use
deserialize_record(data=json.dumps(canonical, allow_nan=False)).
The nested result body is unversioned and must not be persisted on its own.
Malformed JSON and invalid record values raise SchemaError; unsupported
versions raise its UnsupportedSchemaVersionError subclass.
The adapter validates nested fields without string, boolean, or integer
coercion. The parsed body is checked for JSON-only values before strict
JSON-mode validation reconstructs the dataclasses. Missing fields use their
declared defaults; explicit null is accepted only on nullable fields. Payload
IDs must be recorded, not generated during deserialization. These boundary
rules do not replace the normal dataclass constructors used during execution.
When recorded, result_index must be a nonnegative integer. Population references
require a nonempty string id, a positive size, a zero-based index less than
size, and a finite threshold in [0, 1]. Live PopulationRef constructors
enforce these invariants, and the canonical adapter revalidates existing instances
at the write boundary as well as decoded records. Scalar bounds, including the
nonempty ID, are included in the generated JSON Schema.
Result.final_trace_evaluation records evaluation of the trace when execution
stops, including when the turn budget is reached.
Turn.eval_result remains separate online evidence; Turn.eval_purpose records
why that online evaluation ran. A non-null purpose requires an evaluation on the
same turn. Result.trace_end_reason records why turn production stopped. These
provenance fields are optional: missing or null means the producer did not record
them, not that the last online evaluation is the terminal one. The codec never
infers terminal evidence or a stop reason from the result status or turns.
Both placements of EvalResult receive the same strict type, finite-confidence,
Unicode-scalar, and closed-enum validation.
Body encoding validates the live result and uses Pydantic's JSON-mode serialization, with adapter-local Unicode validation and Python ISO datetime formatting. The writer does not reconstruct its output through the reader.
Nesting support follows the underlying serializer, parser, and interpreter limits;
these can vary by platform and runtime. The codec does not promise a portable
maximum depth or matching reader/writer depth support. In particular,
some deeply nested external records can decode successfully but fail when
re-encoded when Pydantic's JSON-mode writer has a lower nesting limit.
These failures raise SchemaError; successful decoding alone does not guarantee
that an unusually deep record can be emitted again.
Canonical serialization policies belong to the cached adapter, not to the public
dataclass annotations or configuration. Shared dataclass definitions reuse the
adapter's configured copy at every occurrence. Fields remain dict[str, Any] and
datetime | None. Independently constructed Pydantic adapters retain their
normal behavior, including live binary payload support and ordinary instance
validation. Constructor invariants still apply when those adapters construct a
new instance.
The canonical adapter supplies its own datetime / Path resolution namespace;
the shared types module keeps those imports under TYPE_CHECKING.
ResultRecord.json_schema() returns the adapter-derived body schema plus the
versioned envelope. Small schema customizations describe the trace-only payload
restrictions and the request invariant (a prompt or at least one attachment).
It also describes the dependency between a turn's purpose and evaluation.
JsonSchemaValue is the return type, not a separate model or validator.
The active open Draft 2020-12 contract is committed at
schemas/trace.v2.schema.json; schemas/trace.v1.schema.json is retained unchanged
as the historical description of v1.
Regenerate it with uv run python scripts/generate_trace_schema.py.
The generator selects the filename from TRACE_SCHEMA_VERSION. CI runs the same
command with --check to detect drift.
Required compatibility decision¶
Schema drift checking alone does not establish compatibility. A separate CI gate
requires a checked-in decision in schemas/trace-compatibility.json, bound to the
contract content by SHA-256 fingerprints. The inputs are result.py, types.py,
serialization.py, _schema.py, _population.py, and all published
trace.v*.schema.json files.
Watching the models and codec policies also catches changes that do not appear
in JSON Schema. This is deliberately conservative: even a nonsemantic edit to
these inputs needs a compatibility rationale.
For a contract change, update the declaration:
initialintroduces the first contract where the PR base has no trace schema.compatibleretains the current major and explains why the change preserves compatibility, such as an additive-optional field with a defined absence behavior.new-majorincrements the major by one, retains earlier published schema files, and includes nonempty inline migration instructions inmigration_note. The note explains the break and the actual reader/migration support shipped; it does not require an upcaster or dual reader.
Historical schema files remain unchanged in subsequent same-major PRs, not just
during a major bump. A compatible decision may update the active major's schema;
a major bump must preserve every schema file already published at the PR base.
The declaration records the current version, contract_sha256,
previous_contract_sha256, decision, and rationale. For a change to an existing
contract, the previous fingerprint must match the PR base's declaration. Obtain
the current fingerprint after regenerating the schema:
uv run python scripts/check_trace_compatibility.py --fingerprint
uv run python scripts/check_trace_compatibility.py --base-ref <PR-base-commit>
PR CI compares against the actual target base commit; a stale declaration fails
even if the schema was regenerated. Unchanged contracts need no new decision.
Without --base-ref, including on main-branch pushes, the command checks the
declaration's version and current content fingerprint only.
The declaration is a review gate, not proof of compatibility. Reviewers must
assess the rationale, semantic behavior, and any claimed migration support.
A regenerated schema or a compatible assertion does not make a breaking change
safe. Keep the input list current if contract policy moves to additional modules.
Structural schema and decoder semantics¶
The JSON Schema checks structure; passing it is necessary but not sufficient
for successful record decoding. Use deserialize_record() for the complete
contract.
The decoder additionally enforces these representation rules:
- Integer fields use integer notation, not floating-point notation. JSON Schema
accepts
0.0as an integer mathematically; the strict decoder rejects it for fields such asresult_index,turn_number, and populationindex/size. - Timestamp strings must parse with Python's
datetime.fromisoformat(). The schema intentionally does not claim RFC 3339 validation, since Python supports naive datetimes and subminute UTC offsets. - Numbers must be finite and representable by the corresponding Python field. For example, an overflowing JSON exponent cannot become an infinite float.
- Strings and mapping keys must contain Unicode scalar values. Surrogate code points in Python strings are rejected, not replaced or combined. Valid JSON surrogate-pair escapes for characters such as emoji remain supported.
- A population's
indexmust be less than itssize. Standard JSON Schema cannot compare these two field values; this cross-field requirement is documented in the generated schema and enforced during canonical encoding and decoding.
External producers should emit integer notation for integer fields, supported ISO datetime strings, and finite numbers, then exercise the canonical reader as well as structural schema validation. These are decoder requirements, not additional serializers.
Transport compatibility boundary¶
This section defines integration requirements. No canonical transport preparation API is implemented yet.
The canonical codec preserves supported values; it does not provide a lenient transport mode. Existing transports and flat reports can accept data outside that domain, so adopting the codec is not a direct replacement of their current serialization calls.
Transport normalization must happen before canonical encoding when the live result contains unsupported values. Prepare a separate result without mutating the original, then use the same record codec. Caps, rendering sanitization, worker bookkeeping, and explicit loss/truncation markers remain transport responsibilities. None belongs in a second field-by-field result serializer.
The codec does not reserve or filter metadata keys. Reporting and transport integration must share an explicit preparation policy for separating bookkeeping from user metadata, rather than maintaining independent exclusion lists. Apply that policy to a separate prepared result, preserving nested user mappings and the original result. Loss/truncation information must remain visible in the appropriate transport envelope or report; removing bookkeeping must not erase evidence of loss. Direct serialization does not perform this preparation.
A text placeholder prepared from a binary payload is a lossy transport view, not a durable copy of that payload. Original format/path information must remain available for transport diagnostics, and the containing transport must identify the loss. Do not persist that view as a full-fidelity replay artifact. The canonical reader itself never performs this conversion or opens a worker path. Supporting durable binary artifacts requires a separately designed representation and compatibility review; adding optional artifact metadata alone does not make currently rejected formats readable by older readers.
Versioning¶
- Every serialized record carries one root
versionfield. The current schema isrampart.trace.v2. - The record version is independent of transport or projection versions, including xdist's versioned envelope. A transport's cadence or version number does not select the canonical trace major.
- There is a single root version — nested types (
Turn,Payload,EvalResult, …) do not carry their own versions.
What is and is not a breaking change¶
- Additive-optional = no bump. A new optional field that older readers may ignore, and whose absence has a defined default, does not change the major.
- Missing = not recorded (not "false"). An absent optional field means the
producer did not record it — never that its value was empty, false, or zero.
Readers supply a default for shape only; consumers must not infer a semantic
negative from absence. An absent optional provenance field means "provenance
was not captured," not "no such event occurred."
This is interpretation guidance, not field-presence tracking: decoding uses
defaults and does not retain which fields were absent. For example, omitted
turnsbecomes[]and is emitted when re-encoded. - Structural change = major bump. Removing, renaming, or retyping a field,
changing its meaning or nesting, or narrowing its accepted value domain bumps
vN → vN+1with a changelog and a migration note. Rejecting previously accepted empty population IDs is a domain-narrowing change, not an optional addition.
flowchart TD
change([proposed schema change]) --> q1{"adds a field only?"}
q1 -- no --> struct["structural:<br/>remove / rename / retype /<br/>change meaning, nesting, or accepted values"]
q1 -- yes --> q2{"optional with a<br/>well-defined default?"}
q2 -- no --> struct
q2 -- yes --> add["additive-optional"]
add --> nobump["NO bump<br/>(new optional fields)<br/>old readers ignore unknown keys"]
struct --> bump["bump major vN → vN+1<br/>+ changelog + migration note"]
bump --> reader["readers: fail closed on<br/>unknown major"]
Reader posture¶
- Readers tolerate unknown fields and fail closed on an unsupported major — a record is never best-effort parsed across a major boundary.
- This reader supports only v2. Retaining the v1 schema does not register a v1
decoder; v1 records raise
UnsupportedSchemaVersionError, as do future majors. - Forward compatibility is additive-only within a major. A newer major read by an older framework fails closed by design.
- Schema descriptions and validators derived from this format must remain open to unknown properties within a major version.
Enum posture¶
- The closed enums —
SafetyStatus,EvalOutcome,EvaluationPurpose,TraceEndReason,ObservabilityLevel, andPayloadFormat— fail closed on an unknown value. A serialized safety result must never silently misread one; there is no warn-and-degrade path. HarmCategoryis the sole exception: it travels as a passthrough string and is never coerced, so a new harm label from a future producer round-trips unchanged on an older reader.
Value domain¶
- Free-form mappings must already contain JSON-safe values: null, strings, booleans, finite numbers, lists, and string-keyed mappings. Tuples, bytes, cycles, and opaque objects are rejected rather than coerced.
- Numeric values must be finite. Transport-specific normalization is outside the canonical schema.
- Strings and mapping keys must contain Unicode scalar values, including optional attribution. Encoding rejects surrogate-containing strings before returning JSON text; decoding rejects unpaired surrogate escapes.
- Timestamps retain Python's ISO 8601 representation, including naive datetimes
and UTC offsets. The schema describes strings rather than RFC 3339
date-time, which would exclude some supported Python datetimes. rampart.trace.v2does not define a durable representation for binary or opaque payload artifacts. Encoding or decoding one fails closed rather than coercing it to text.- Encoding and decoding preserve supported metadata, including keys used for transport bookkeeping or loss/truncation markers. Unsupported values are rejected regardless of the key name. Metadata hygiene belongs to consumer preparation, not to the canonical codec.
Migration mechanics¶
Writers emit the latest supported major. Each major bump records its migration
instructions and actual reader/tooling support inline in
schemas/trace-compatibility.json.
- Migrating persisted data is an explicit operation into separate records; preserve the originals and validate converted records against the target major.
- Reading never rewrites an artifact in place or silently relabels its version.
- Encountering an unsupported major fails closed.
- Under the current support policy, an executable migration utility, adjacent upcaster, or dual reader is not required.
Future extensions¶
Introduce fields when their producers, consumers, and absence semantics are
defined. This schema does not reserve future field names or shapes. Wire-only
attribution belongs on ResultRecord; data intrinsic to a result belongs on
Result, inside the referenced result body.
Optional additions that older readers may ignore and whose absence has a defined default do not require a major bump. Structural changes do. Apply the compatibility review and declaration requirements to each extension rather than promising compatibility for an unimplemented representation.
Support policy¶
Schema compatibility guarantees follow RAMPART's project-wide deprecation policy, not a specific release number. The current policy does not promise deprecation periods, compatibility aliases, a fixed support window, dual readers, or mandatory upcasters. Revisit these guarantees when the project-wide deprecation policy changes.
Every canonical major change still requires an explicit version/compatibility decision, unchanged historical schema descriptions, and a changelog entry and migration note describing actual support. Unsupported versions always fail closed.