Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ jobs:
- name: ty
run: uv run ty check

- name: Trace schema drift
run: uv run python scripts/generate_trace_schema.py --check

test:
name: Test (Python ${{ matrix.python-version }})
needs: lint
Expand Down
137 changes: 137 additions & 0 deletions docs/concepts/trace-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Trace/Result Schema & Migration Policy

`rampart.core.serialization` defines RAMPART's canonical, versioned
`Result`-record format. `ResultRecord.to_dict()` / `ResultRecord.from_dict()` own
the versioned envelope and optional `pytest_nodeid` / `result_index` attribution.
`serialize_result()` / `deserialize_result()` are convenience functions. 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

`Result.to_dict()` / `Result.from_dict()` own the **unversioned body**, using one
cached Pydantic `TypeAdapter` over the existing standard dataclasses. Body dicts
are fragments, not standalone durable records: persist a `ResultRecord` to
include the version. `ResultRecord` references the live result; serialization
does not mutate it.

The adapter validates nested fields without string, boolean, or integer
coercion. Dictionary input 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.

`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).
`JsonSchemaValue` is the return type, not a separate model or validator.
The open Draft 2020-12 contract is committed at `schemas/trace.v1.schema.json`.

Regenerate it with `uv run python scripts/generate_trace_schema.py`.
CI runs the same command with `--check` to detect drift. Changes to generated
output still require a compatibility review; generation does not decide whether
a version bump is needed.

## Versioning

- Every serialized record carries one root `version` field. The current schema
is **`rampart.trace.v1`**.
- The record version is **independent** of transport or projection versions,
including the existing xdist envelope version (`rampart.xdist.v2`). Each
version describes its own layer and may evolve separately.
- 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. A v1 record with no `manifest_snapshot` means "the
manifest was not captured," not "there was no manifest."
- **Structural change = major bump.** Removing, renaming, or retyping a field,
or changing its meaning or nesting, bumps `vN → vN+1` with a changelog and a
migration note.

## Reader posture

- Readers tolerate unknown fields and **fail closed on an unknown major** — a
record is never best-effort parsed across a major boundary.
- 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`, `ObservabilityLevel`, and
`PayloadFormat` — **fail closed** on an unknown value. A serialized safety
result must never silently misread one; there is no warn-and-degrade path.
- `HarmCategory` is 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.
- 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.v1` does not define a durable representation for binary or
opaque payload artifacts. Encoding or decoding one fails closed rather than
coercing it to text.
- `ResultRecord` removes transport bookkeeping keys, including
`_rampart_source_worker`, from top-level `Result.metadata`; body serialization
does not. Nested user mappings are preserved.

## Migration mechanics

Only `rampart.trace.v1` exists today. If a later structural change introduces a
new major:

- writers emit the latest supported major;
- support for an older major uses an explicit adjacent upcaster
(`vN-1 → vN`);
- migrating persisted data is an explicit operation; reading never rewrites an
artifact in place; and
- encountering an unsupported major fails closed.

## Reserved additive fields (named now, populated later)

These record-level wire-only collar slots are reserved by name so they can be
added without a major bump:
`manifest_snapshot`, `evaluation_fingerprint`, `replay_provenance`,
`population_ref`, plus `artifacts` / `target` / `provenance`. A field that is
truly *intrinsic to a result* instead lands as an additive-optional field on
`Result`, inside the referenced `result` body. Either way each is
additive-optional; none is populated at v1.

Other future fields follow the same general rule: optional additions with a
defined absence behavior do not require a major bump; structural changes do.

## Support window

Starting with the first release that writes durable trace records by default,
RAMPART supports reading `vN` and `vN-1` for **two subsequent framework
releases** (one deprecation cycle). The window is keyed on releases, not time.
Any major bump includes a changelog entry and migration note.

```mermaid
flowchart TD
change([proposed schema change]) --> q1{"adds a field only?"}
q1 -- no --> struct["structural:<br/>remove / rename / retype /<br/>change meaning or nesting"]
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"]
```
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ nav:
- Attacks: concepts/attacks.md
- Probes: concepts/probes.md
- PyRIT Integration: concepts/pyrit.md
- Trace Schema & Migration: concepts/trace-schema.md
- Attacks:
- attacks/index.md
- XPIA: attacks/xpia.md
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ dev = [
"flake8>=7.3.0",
"hatch-vcs>=0.5.0",
"hatchling>=1.30.1",
"jsonschema>=4.26.0",
"pre-commit>=4.5.1",
"pytest-cov>=6.1.0",
"pytest-xdist[psutil]>=3.8.0",
Expand Down Expand Up @@ -128,6 +129,9 @@ external = ["RMP001", "RMP002"]
"scripts/hatch_build.py" = [
"implicit-namespace-package", # Top-level build hook
]
"scripts/generate_trace_schema.py" = [
"implicit-namespace-package", # Standalone schema generation command
]
"tests/integration/conftest.py" = [
"unused-noqa", # Ruff 0.16.4 does not recognize pytest-fixture-autouse.
]
Expand Down
123 changes: 123 additions & 0 deletions rampart/core/_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT license.

"""Shared value-domain rules for dataclass trace adapters."""

from __future__ import annotations

import math
from collections.abc import Mapping
from datetime import datetime
from typing import (
TYPE_CHECKING,
Annotated,
Any,
TypeAlias,
)

from pydantic import (
BeforeValidator,
PlainSerializer,
WithJsonSchema,
)

if TYPE_CHECKING:
from pydantic import ValidationError, ValidationInfo


def json_value(value: object) -> object:
"""Check and copy JSON values without lossy coercion.

Returns:
object: JSON primitives, lists, and string-keyed dictionaries.

Raises:
ValueError: If a value is non-finite, cyclic, or outside the JSON domain.
"""
return _json_value(value=value, path="$", active=set())


def _json_value(*, value: object, path: str, active: set[int]) -> object:
"""Recursively validate the JSON domain, retaining the offending path.

Returns:
object: A JSON-safe copy.

Raises:
ValueError: If the value cannot be represented faithfully in JSON.
"""
if value is None or isinstance(value, str | bool | int):
return value
if isinstance(value, float) and math.isfinite(value):
return value
if not isinstance(value, Mapping | list):
msg = f"{path}: {type(value).__name__} is outside the finite JSON domain"
raise ValueError(msg) # ruff: ignore[type-check-without-type-error] Pydantic wraps ValueError.
if id(value) in active:
msg = f"{path}: cyclic JSON value"
raise ValueError(msg)
active.add(id(value))
try:
if isinstance(value, list):
return [
_json_value(value=item, path=f"{path}[{index}]", active=active)
for index, item in enumerate(value)
]
result: dict[str, object] = {}
for key, item in value.items():
if not isinstance(key, str):
msg = f"{path}: JSON object keys must be strings"
raise ValueError(msg) # ruff: ignore[type-check-without-type-error] Pydantic wraps ValueError.
result[key] = _json_value(value=item, path=f"{path}.{key}", active=active)
return result
finally:
active.remove(id(value))


# Standard dataclass construction stays permissive; adapters validate these maps.
JsonMapping: TypeAlias = Annotated[dict[str, Any], BeforeValidator(json_value)]


# Pydantic supplies value/info positionally to BeforeValidator callbacks.
def _iso_datetime(value: object, info: ValidationInfo) -> object:
"""Retain Python ISO datetime support, including naive and subminute offsets.

Returns:
object: Parsed datetime strings, or the unchanged value for validation.

Raises:
ValueError: If the string is not an ISO datetime.
"""
if info.mode == "json" and isinstance(value, str):
try:
return datetime.fromisoformat(value)
except ValueError as exc:
msg = "expected an ISO 8601 datetime string"
raise ValueError(msg) from exc
return value


IsoDatetime: TypeAlias = Annotated[
datetime,
BeforeValidator(_iso_datetime),
PlainSerializer(datetime.isoformat),
# Python ISO datetimes include values outside RFC 3339's date-time format.
WithJsonSchema(
{"type": "string", "description": "ISO 8601 datetime; UTC offset is optional."}
),
]


def validation_message(*, error: ValidationError, path: str) -> str:
"""Render Pydantic errors without including producer data in the message.

Returns:
str: Field paths and validation reasons.
"""
messages: list[str] = []
for detail in error.errors(include_url=False, include_input=False):
location = path
for part in detail["loc"]:
location += f"[{part}]" if isinstance(part, int) else f".{part}"
messages.append(f"{location}: {detail['msg']}")
return "; ".join(messages)
8 changes: 8 additions & 0 deletions rampart/core/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,11 @@ class EvaluatorError(InfrastructureError):
``InfrastructureError`` base class) and produces a Result with
SafetyStatus.ERROR.
"""


class SchemaError(Exception):
"""A value cannot be represented by the canonical trace schema."""


class UnsupportedSchemaVersionError(SchemaError):
"""A record's version has no registered decoder."""
Loading
Loading