diff --git a/schemas/v0.1/canonical-event.schema.json b/schemas/v0.1/canonical-event.schema.json new file mode 100644 index 0000000..2e5cf11 --- /dev/null +++ b/schemas/v0.1/canonical-event.schema.json @@ -0,0 +1,118 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://wasmagent.dev/schemas/v0.1/canonical-event.schema.json", + "title": "CanonicalEvent", + "description": "Canonical event schema — the foundational, extensible event structure for the Agent Evidence Protocol. Maps onto the AEP record model and provides a single-event envelope that adapters can produce from heterogeneous upstream event formats.", + "type": "object", + "required": ["schema_version", "event_id", "event_type", "timestamp_ms", "source"], + "properties": { + "schema_version": { + "type": "string", + "enum": ["canonical-event/v0.1"], + "description": "Schema version discriminator. Consumers MUST reject unknown values." + }, + "event_id": { + "type": "string", + "description": "Unique identifier for this event within its trace (UUIDv7 or equivalent)." + }, + "event_type": { + "type": "string", + "description": "Qualified event type name, e.g. 'tool.invocation', 'capability.decision', 'verifier.result'. Dot-separated namespace convention." + }, + "timestamp_ms": { + "type": "number", + "description": "Epoch-millisecond timestamp when the event occurred." + }, + "source": { + "type": "object", + "required": ["run_id"], + "description": "Provenance: identifies the agent run that produced this event.", + "properties": { + "run_id": { + "type": "string", + "description": "Unique identifier for the agent run." + }, + "trace_id": { + "type": "string", + "description": "Trace identifier for distributed correlation." + }, + "parent_event_id": { + "type": ["string", "null"], + "description": "Parent event in a causal chain, if any." + }, + "agent_id": { + "type": "string", + "description": "Logical agent identifier." + }, + "runtime_version": { + "type": "string", + "description": "Runtime version string, e.g. 'wasmagent-js@1.20.0'." + }, + "model_provider": { + "type": "string", + "description": "LLM provider, e.g. 'anthropic', 'openai'." + }, + "model_id": { + "type": "string", + "description": "Model identifier, e.g. 'claude-sonnet-5-20250514'." + } + } + }, + "payload": { + "type": "object", + "description": "Event-type-specific body. Schema is determined by event_type; consumers should dispatch on that field. This field is extensible and intentionally untyped at the protocol level." + }, + "correlation": { + "type": "object", + "description": "Optional cross-event correlation metadata.", + "properties": { + "causal_chain_id": { + "type": "string", + "description": "Groups events sharing a causal relationship." + }, + "scope_lease_id": { + "type": "string", + "description": "Capability scope lease, if applicable." + } + } + }, + "security": { + "type": "object", + "description": "Security and attestation metadata attached to this event.", + "properties": { + "taint_labels": { + "type": "array", + "items": { "type": "string" }, + "description": "Data-flow taint labels propagated from inputs." + }, + "capability_decision": { + "type": "object", + "required": ["capability", "decision"], + "description": "Capability decision governing this event, if applicable.", + "properties": { + "capability": { "type": "string" }, + "subject": { "type": "string" }, + "resource": { "type": "string" }, + "decision": { + "type": "string", + "enum": ["allow", "deny", "ask_user", "dry_run"] + }, + "reason_code": { "type": "string" } + } + } + } + }, + "signature": { + "type": "object", + "required": ["alg", "key_id", "sig"], + "description": "Cryptographic signature over this event.", + "properties": { + "alg": { "type": "string" }, + "key_id": { "type": "string" }, + "sig": { "type": "string" }, + "bundle": { "type": "object" }, + "transparency_log_ref": { "type": "string" } + } + } + } +} diff --git a/tests/fixtures/invalid/canonical-event/example.json b/tests/fixtures/invalid/canonical-event/example.json new file mode 100644 index 0000000..eadbd3c --- /dev/null +++ b/tests/fixtures/invalid/canonical-event/example.json @@ -0,0 +1,4 @@ +{ + "schema_version": "canonical-event/v9.9", + "timestamp_ms": 1737600000100 +} diff --git a/tests/fixtures/valid/canonical-event/example.json b/tests/fixtures/valid/canonical-event/example.json new file mode 100644 index 0000000..16b9dfd --- /dev/null +++ b/tests/fixtures/valid/canonical-event/example.json @@ -0,0 +1,33 @@ +{ + "schema_version": "canonical-event/v0.1", + "event_id": "evt-01jxq5k2r-abc123", + "event_type": "tool.invocation", + "timestamp_ms": 1737600000100, + "source": { + "run_id": "run-abc123", + "trace_id": "trace-001", + "agent_id": "agent-1", + "runtime_version": "wasmagent-js@1.20.0", + "model_provider": "anthropic", + "model_id": "claude-sonnet-5" + }, + "payload": { + "tool_name": "write_file", + "state_changing": true, + "args": { "path": "/tmp/out.txt" } + }, + "security": { + "taint_labels": ["user-input"], + "capability_decision": { + "capability": "fs.write", + "subject": "agent-1", + "resource": "/tmp/out.txt", + "decision": "allow" + } + }, + "signature": { + "alg": "ed25519", + "key_id": "k1", + "sig": "base64sig==" + } +} diff --git a/tests/loader.test.js b/tests/loader.test.js index 5d82b18..91703bf 100644 --- a/tests/loader.test.js +++ b/tests/loader.test.js @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { test } from 'node:test'; import { getSchema, index, schemas } from '../index.js'; +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); + test('registry lists at least one schema', () => { assert.ok(index.schemas.length >= 1); }); @@ -23,3 +28,53 @@ test('schemas map is keyed by id', () => { test('getSchema throws on unknown id', () => { assert.throws(() => getSchema('does-not-exist'), /unknown schema id/); }); + +// --------------------------------------------------------------------------- +// Consumer-surface guarantee for wasmagent-js (Milestone 1 — single source of +// truth). wasmagent-js must be able to delete its local schema copies — +// packages/compliance/schemas/* and +// packages/core/src/ranking/schemas/rollout-wire.schema.json — and import them +// from @wasmagent/protocol instead. Deleting those copies lives in the +// wasmagent-js repo (tracked via cross-repo note); the in-repo half of the +// contract — that every such schema is registered, declared as a wasmagent-js +// consumer, and reachable through the published package surface — is locked +// down here so the consumer-repo deletion can never silently regress. +const WASMAGENT_JS_SCHEMAS = [ + 'rollout-wire', + 'constraint-ir', + 'constraint-violation', + 'repair-trace', + 'task-spec', + 'compliance-eval-record', +]; + +test('every schema wasmagent-js consumes is registered, declared, and published', () => { + const byId = new Map(index.schemas.map((s) => [s.id, s])); + for (const id of WASMAGENT_JS_SCHEMAS) { + const entry = byId.get(id); + assert.ok( + entry, + `${id} is missing from the registry — wasmagent-js cannot drop its local copy`, + ); + assert.ok( + entry.consumers.includes('wasmagent-js'), + `${id} does not declare wasmagent-js as a consumer`, + ); + // Programmatic import path: getSchema() must serve the live document. + assert.equal(getSchema(id).$id, entry.canonical_id, `${id} getSchema $id mismatch`); + // File import path: the schema file must ship so the ./schemas/* subpath + // export can serve it to consumers. + assert.ok( + existsSync(join(ROOT, entry.path)), + `${entry.path} is not on disk — the ./schemas/* subpath export cannot serve it`, + ); + } +}); + +test("package.json exports the './schemas/*' subpath wasmagent-js imports from", () => { + const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); + assert.ok( + Object.keys(pkg.exports).includes('./schemas/*'), + "package.json exports must map './schemas/*' so consumers can import schema files directly", + ); +}); diff --git a/tests/test_conformance.py b/tests/test_conformance.py new file mode 100644 index 0000000..40188cf --- /dev/null +++ b/tests/test_conformance.py @@ -0,0 +1,24 @@ +"""Pytest bridge for the conformance harness. + +The canonical conformance checks live in ``conformance.py`` and are invoked +directly (``python3 tests/conformance.py``, per ``CLAUDE.md`` and CI). This +module re-exposes them under ``pytest tests/`` so the package's standard test +command runs the full schema conformance suite instead of exiting with "no +tests ran" (pytest exit code 5). +""" +import subprocess +import sys +from pathlib import Path + + +def test_conformance_harness_passes(): + harness = Path(__file__).parent / "conformance.py" + result = subprocess.run( + [sys.executable, str(harness)], + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"conformance.py exited {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + )