diff --git a/index.d.ts b/index.d.ts index 465117e..62aace0 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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; } /** Machine-readable registry of every canonical schema. */ @@ -31,6 +42,12 @@ export const schemas: Record; */ export function getSchema(id: string): unknown; +/** Grouped schema families (e.g. "aep") for cross-cutting discovery. */ +export const families: Record; + +/** Registry ids of the concrete member schemas in a family, or [] if unknown. */ +export function familyMembers(family: string): string[]; + // --------------------------------------------------------------------------- // Cross-repo schema drift detection. // --------------------------------------------------------------------------- diff --git a/index.js b/index.js index ed04f0d..bb60426 100644 --- a/index.js +++ b/index.js @@ -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. // diff --git a/schemas/aep/_base.schema.json b/schemas/aep/_base.schema.json new file mode 100644 index 0000000..ea7d7fb --- /dev/null +++ b/schemas/aep/_base.schema.json @@ -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 + } + } +} diff --git a/schemas/index.json b/schemas/index.json index 2bc43b1..a61fea6 100644 --- a/schemas/index.json +++ b/schemas/index.json @@ -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", @@ -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" + ] + } + } } diff --git a/src/wasmagent_protocol/__init__.py b/src/wasmagent_protocol/__init__.py index 72fd3df..6693bac 100644 --- a/src/wasmagent_protocol/__init__.py +++ b/src/wasmagent_protocol/__init__.py @@ -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 @@ -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. diff --git a/tests/conformance.py b/tests/conformance.py index 3b81841..c5cccca 100644 --- a/tests/conformance.py +++ b/tests/conformance.py @@ -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 [] @@ -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/"}`` 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: diff --git a/tests/fixtures/invalid/aep-base/example.json b/tests/fixtures/invalid/aep-base/example.json new file mode 100644 index 0000000..7c79d65 --- /dev/null +++ b/tests/fixtures/invalid/aep-base/example.json @@ -0,0 +1,6 @@ +{ + "schema_version": "aep/v0.4", + "evidence_type": "artifact-attestation", + "produced_by_run_id": "", + "created_at_ms": 1737600000000 +} diff --git a/tests/fixtures/valid/aep-base/example.json b/tests/fixtures/valid/aep-base/example.json new file mode 100644 index 0000000..56c63d8 --- /dev/null +++ b/tests/fixtures/valid/aep-base/example.json @@ -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==" + } +}