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
17 changes: 17 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,22 @@ export interface SchemaIndexEntry {
summary: string;
}

export interface SchemaFamily {
title: string;
description: string;
/** Registry id of the base/reference schema every member extends. */
base: string;
/** Registry ids of the concrete member schemas in the family. */
members: string[];
}

export interface SchemaIndex {
protocol: string;
description: string;
canonical_host: string;
schemas: SchemaIndexEntry[];
/** Grouped schema families (e.g. "aep") for cross-cutting discovery. */
families?: Record<string, SchemaFamily>;
}

/** Machine-readable registry of every canonical schema. */
Expand All @@ -31,6 +42,12 @@ export const schemas: Record<string, unknown>;
*/
export function getSchema(id: string): unknown;

/** Grouped schema families (e.g. "aep") for cross-cutting discovery. */
export const families: Record<string, SchemaFamily>;

/** Registry ids of the concrete member schemas in a family, or [] if unknown. */
export function familyMembers(family: string): string[];

// ---------------------------------------------------------------------------
// Cross-repo schema drift detection.
// ---------------------------------------------------------------------------
Expand Down
9 changes: 9 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ export function getSchema(id) {
/** All schemas as a plain object keyed by id. */
export const schemas = Object.fromEntries(index.schemas.map((s) => [s.id, getSchema(s.id)]));

/** Grouped schema families (e.g. "aep") for cross-cutting discovery. */
export const families = index.families ?? {};

/** Registry ids of the concrete member schemas in a family, or [] if unknown. */
export function familyMembers(family) {
const fam = (index.families || {})[family];
return Array.isArray(fam && fam.members) ? [...fam.members] : [];
}

// ---------------------------------------------------------------------------
// Cross-repo schema drift detection.
//
Expand Down
71 changes: 71 additions & 0 deletions schemas/aep/_base.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://wasmagent.dev/schemas/aep/_base.schema.json",
"title": "AEPBase",
"description": "Common envelope that every AEP (Agent Evidence Provenance) schema extends. Defines the shared provenance/timestamp fields and produced_by_run_id (string, non-empty) that downstream evidence schemas reuse via the $defs block. This is a base/reference schema, not a concrete evidence record: concrete schemas (aep-record, evidence-envelope, and the planned artifact-attestation / replay-evidence / checkpoint-evidence records) build on it.",
"type": "object",
"required": [
"schema_version",
"evidence_type",
"produced_by_run_id",
"created_at_ms"
],
"properties": {
"schema_version": { "$ref": "#/$defs/schema_version" },
"evidence_type": { "$ref": "#/$defs/evidence_type" },
"trace_id": { "$ref": "#/$defs/trace_id" },
"parent_trace_id": { "$ref": "#/$defs/parent_trace_id" },
"created_at_ms": { "$ref": "#/$defs/created_at_ms" },
"produced_by_run_id": { "$ref": "#/$defs/produced_by_run_id" },
"signature": { "$ref": "#/$defs/signature" }
},
"$defs": {
"schema_version": {
"type": "string",
"minLength": 1,
"description": "Semver-like schema identifier, e.g. aep/v0.4"
},
"evidence_type": {
"type": "string",
"minLength": 1,
"description": "Discriminator naming the concrete evidence record type this envelope carries (e.g. aep-record, artifact-attestation, replay-evidence)."
},
"trace_id": {
"type": "string",
"description": "Correlation ID linking related evidence records in the same run/trace."
},
"parent_trace_id": {
"type": ["string", "null"],
"description": "Parent trace when this evidence belongs to a nested/subagent trace."
},
"created_at_ms": {
"type": "number",
"description": "UTC epoch milliseconds when the record was created."
},
"produced_by_run_id": {
"type": "string",
"minLength": 1,
"description": "Non-empty identifier of the run that produced this evidence. Reused by every downstream AEP evidence schema; never null or empty."
},
"signature": {
"type": "object",
"description": "Optional tamper-evident signature over the record content.",
"required": ["alg", "key_id", "sig"],
"properties": {
"alg": {
"type": "string",
"description": "Signature algorithm, e.g. ed25519"
},
"key_id": {
"type": "string",
"description": "Identifier of the signing key"
},
"sig": {
"type": "string",
"description": "Base64-encoded signature bytes"
}
},
"additionalProperties": false
}
}
}
32 changes: 31 additions & 1 deletion schemas/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,25 @@
"description": "Registry of canonical WasmAgent protocol schemas \u2014 the single source of truth for every cross-repository contract. Downstream repos consume via @wasmagent/protocol (npm) and wasmagent-protocol (PyPI). Covers: AEP, compliance, AgentBOM, MCP Posture, Trust Passport.",
"canonical_host": "https://wasmagent.dev/schemas/",
"schemas": [
{
"id": "aep-base",
"title": "AEPBase",
"path": "schemas/aep/_base.schema.json",
"canonical_id": "https://wasmagent.dev/schemas/aep/_base.schema.json",
"version": "aep-base/v0.1",
"stability": "evolving",
"owners": [
"wasmagent-protocol"
],
"consumers": [
"wasmagent-js",
"wasmagent-proxy",
"trace-pipeline",
"wasmagent-train-replay",
"open-agent-audit"
],
"summary": "Common envelope every AEP evidence schema extends — schema_version, evidence_type, created_at_ms, trace_id, and produced_by_run_id (string, non-empty) reused by downstream evidence schemas."
},
{
"id": "aep-record",
"title": "AEPRecord",
Expand Down Expand Up @@ -185,5 +204,16 @@
],
"summary": "Trust Passport \u2014 signed, expiring, verifiable trust-state artifact for AI agents."
}
]
],
"families": {
"aep": {
"title": "Agent Evidence Provenance",
"description": "Runtime action and provenance evidence records emitted by WasmAgent agents. Every member extends the shared AEP base envelope (aep-base), which defines produced_by_run_id and the common timestamp/provenance fields.",
"base": "aep-base",
"members": [
"aep-record",
"evidence-envelope"
]
}
}
}
28 changes: 27 additions & 1 deletion src/wasmagent_protocol/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,14 @@
from pathlib import Path
from typing import Any

__all__ = ["INDEX", "get_schema", "schema_path", "schema_ids"]
__all__ = [
"INDEX",
"get_schema",
"schema_path",
"schema_ids",
"schema_families",
"family_members",
]

_SCHEMAS_PKG = "wasmagent_protocol.schemas"
# Editable / source-checkout fallback: the canonical schemas live at the repo
Expand Down Expand Up @@ -72,6 +79,25 @@ def schema_ids() -> list[str]:
return list(_PATHS)


def schema_families() -> dict[str, Any]:
"""Return the grouped schema families declared in the registry (e.g. ``aep``).

Each family maps to ``{"title", "description", "base", "members"}`` where
``base`` is the registry id of the shared base schema every member extends
and ``members`` lists the concrete member schema ids.
"""
families = INDEX.get("families")
return dict(families) if isinstance(families, dict) else {}


def family_members(family: str) -> list[str]:
"""Return the member schema ids for ``family`` (e.g. ``"aep"``), or ``[]``
if the family is not declared in the registry."""
fam = schema_families().get(family)
members = fam.get("members") if isinstance(fam, dict) else None
return list(members) if isinstance(members, list) else []


@lru_cache(maxsize=None)
def get_schema(schema_id: str) -> dict[str, Any]:
"""Return the parsed JSON Schema for a registered id.
Expand Down
148 changes: 144 additions & 4 deletions tests/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,10 @@ def build_registry() -> dict[str, dict]:
return registry


def check_index(registry: dict[str, dict]) -> list[dict]:
index_path = SCHEMAS / "index.json"
index = load_json(index_path)
def check_index(index: dict) -> list[dict]:
"""Validate the flat registry: every listed schema exists on disk and its
file $id matches the index's canonical_id. Returns the schemas array
(possibly empty on failure)."""
if not index or "schemas" not in index:
err("schemas/index.json missing or has no 'schemas' array")
return []
Expand Down Expand Up @@ -168,13 +169,152 @@ def check_fixtures(index_entries: list[dict], registry: dict[str, dict]) -> None
err(f"INVALID fixture accepted (should fail): {f.relative_to(ROOT)}")


def _resolve_local_def(node, defs: dict) -> dict | None:
"""Follow a single ``{"$ref": "#/$defs/<name>"}`` pointer to its target in
``defs``; return ``node`` unchanged otherwise. Only single-hop local refs
are resolved — the idiomatic way AEP schemas reuse shared definitions."""
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith("#/$defs/"):
target = defs.get(ref[len("#/$defs/"):])
if isinstance(target, dict):
return target
return node


def _check_base_envelope(path: Path, base: dict) -> None:
"""The AEP base envelope must declare Draft 2020-12, define the common
provenance/timestamp fields, and define produced_by_run_id as a non-empty
string reused by every downstream evidence schema."""
rel = path.relative_to(ROOT)
if base.get("$schema") != "https://json-schema.org/draft/2020-12/schema":
err(f"{rel}: base envelope $schema must be Draft 2020-12")
props = base.get("properties") if isinstance(base.get("properties"), dict) else {}
defs = base.get("$defs") if isinstance(base.get("$defs"), dict) else {}
prid = _resolve_local_def(props.get("produced_by_run_id"), defs)
if not isinstance(prid, dict):
prid = defs.get("produced_by_run_id")
if not isinstance(prid, dict):
err(f"{rel}: base envelope must define produced_by_run_id")
else:
if prid.get("type") != "string":
err(f"{rel}: produced_by_run_id must be a JSON string")
if prid.get("minLength", 0) < 1:
err(f"{rel}: produced_by_run_id must be non-empty (minLength >= 1)")
for field in ("schema_version", "evidence_type", "created_at_ms"):
if field not in props and field not in defs:
err(f"{rel}: base envelope must define the common field {field!r}")


def _run_aep_fixtures_if_present(
path: Path, doc: dict, fixture_id: str, ref_registry
) -> None:
"""Run VALID/INVALID fixtures for an AEP schema when its fixture dirs exist.

No error is raised when fixtures are absent — discovery must still succeed
(a newly-added schema with no fixtures still loads cleanly).
"""
valid_dir = FIXTURES / "valid" / fixture_id
invalid_dir = FIXTURES / "invalid" / fixture_id
valids = sorted(valid_dir.glob("*.json")) if valid_dir.is_dir() else []
invalids = sorted(invalid_dir.glob("*.json")) if invalid_dir.is_dir() else []
if not valids and not invalids:
return
validator = Draft202012Validator(doc, registry=ref_registry)
for f in valids:
inst = load_json(f)
errs = list(validator.iter_errors(inst)) if inst is not None else []
if errs:
err(f"VALID fixture rejected: {f.relative_to(ROOT)}: {errs[0].message}")
for f in invalids:
inst = load_json(f)
if inst is None:
continue
if not list(validator.iter_errors(inst)):
err(f"INVALID fixture accepted (should fail): {f.relative_to(ROOT)}")


def check_aep_family(index: dict, registry: dict[str, dict]) -> None:
"""Discover every schemas/aep/*.schema.json and enforce the shared AEP
base-envelope contract (issue #122 bootstrap).

Discovery is tolerant: a schema with no fixtures still loads without error.
Strict fixture coverage for *registered* schemas is enforced separately by
check_fixtures; this function adds the AEP-family invariants:

- schemas/index.json carries an 'aep' family section whose 'base' names a
schema registered in the flat schemas array, with a 'members' id list.
- schemas/aep/_base.schema.json exists and satisfies the base-envelope
contract (Draft 2020-12, produced_by_run_id as a non-empty string, and the
common provenance/timestamp fields).
- Every schemas/aep/*.schema.json is discovered and loadable; any fixtures
present for a discovered-but-unregistered schema are validated.
"""
aep_dir = SCHEMAS / "aep"
if not aep_dir.is_dir():
err("schemas/aep/: AEP family directory missing")
return

families = index.get("families") if isinstance(index, dict) else None
aep_family = families.get("aep") if isinstance(families, dict) else None
id_by_canonical = {
e.get("canonical_id"): e.get("id")
for e in (index.get("schemas") or [])
if isinstance(e, dict)
}
registered_ids = {i for i in id_by_canonical.values() if isinstance(i, str)}

if not isinstance(aep_family, dict):
err("schemas/index.json: missing 'aep' family section")
else:
base_id = aep_family.get("base")
if not isinstance(base_id, str) or not base_id:
err("schemas/index.json: aep family 'base' must name a registered schema id")
elif base_id not in registered_ids:
err(f"schemas/index.json: aep family base {base_id!r} is not a registered schema")
members = aep_family.get("members")
if not isinstance(members, list) or not all(isinstance(m, str) for m in members):
err("schemas/index.json: aep family 'members' must be a list of schema ids")

discovered = sorted(aep_dir.glob("*.schema.json"))
if not discovered:
err("schemas/aep/: no *.schema.json schemas discovered")

base_path = aep_dir / "_base.schema.json"
if not base_path.is_file():
err("schemas/aep/_base.schema.json: base envelope schema missing")
else:
base = load_json(base_path)
if base is not None:
_check_base_envelope(base_path, base)

# Forward-looking fixture run: validate fixtures for any discovered AEP
# schema that is NOT yet in the flat registry. Registered schemas are owned
# by check_fixtures; here we only guarantee loadability + fixture pass/fail
# for an unregistered schema, tolerating missing fixtures entirely.
if not HAVE_JSONSCHEMA or not discovered:
return
ref_registry = build_reference_registry(registry)
for path in discovered:
doc = load_json(path)
if doc is None:
continue
sid = doc.get("$id")
if isinstance(sid, str) and sid in id_by_canonical:
continue
fixture_id = path.name[: -len(".schema.json")]
_run_aep_fixtures_if_present(path, doc, fixture_id, ref_registry)


def main() -> int:
if not HAVE_JSONSCHEMA:
print("WARNING: jsonschema not installed — running structural checks only.")
registry = build_registry()
index_entries = check_index(registry)
index = load_json(SCHEMAS / "index.json") or {}
index_entries = check_index(index)
check_schemas_valid(registry)
check_refs()
check_aep_family(index, registry)
check_fixtures(index_entries, registry)

if errors:
Expand Down
6 changes: 6 additions & 0 deletions tests/fixtures/invalid/aep-base/example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"schema_version": "aep/v0.4",
"evidence_type": "artifact-attestation",
"produced_by_run_id": "",
"created_at_ms": 1737600000000
}
12 changes: 12 additions & 0 deletions tests/fixtures/valid/aep-base/example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"schema_version": "aep/v0.4",
"evidence_type": "artifact-attestation",
"trace_id": "trace-abc123",
"produced_by_run_id": "run-001",
"created_at_ms": 1737600000000,
"signature": {
"alg": "ed25519",
"key_id": "key-001",
"sig": "base64sigexample=="
}
}
Loading