diff --git a/protocol/evidence-chain.md b/protocol/evidence-chain.md new file mode 100644 index 00000000..cdc65483 --- /dev/null +++ b/protocol/evidence-chain.md @@ -0,0 +1,101 @@ +# Evidence Chain Contract + +The evidence chain contract fixes the MVP relationship between L1 audit records +and L4 agent or worker evidence without changing either implementation surface. +It is a fixture-level contract for now: services may persist the records in their +existing tables, while the chain example binds the identities that must not +split later. + +## Minimal ID Relationship + +Every MVP evidence chain has one `operationId`, one `traceId`, one or more +`auditId` values, and one or more `evidenceId` values. + +- `operationId` is the workflow anchor. It must match the + `hardwareOperation.operationId`, every chain `auditEvents[].operationId`, + every `traceEvents[].operationId`, and every + `evidenceRecords[].operationId`. +- `traceId` is the request and execution correlation ID. It must match every + `auditEvents[].traceId` and every `traceEvents[].traceId`. Because the L0 + `evidence-record` schema does not carry `traceId` at the top level, MVP + evidence records must copy it to `evidenceRecords[].metadata.traceId`. +- `auditId` is unique per audit event in a chain. Each evidence record must set + `metadata.producedByAuditId` to an audit event in the same fixture. The audit + event that records evidence must target that evidence record with + `targetType: "evidence_record"` and `targetId: `. +- `evidenceId` is the stable artifact record ID. It must be unique in a chain, + belong to the same `projectId`, `operationId`, and `environment`, and point to + a content-addressed artifact through `uri`, `sha256`, and optional + `sizeBytes`. + +The same relationship applies whether the producer is a hardware trusted loop +or an agent automation loop. Agent and worker IDs are optional for hardware-only +chains, but when present they must be carried consistently by the operation, +trace events, and evidence records. + +## MVP Required Fields + +The fixture root is not a database table. It exists to verify cross-record +relationships and must include: + +- `chainId` +- `schemaVersion` +- `scenario` +- `projectId` +- `operationId` +- `traceId` +- `environment` +- `hardwareOperation` +- `auditEvents` +- `traceEvents` +- `evidenceRecords` + +`hardwareOperation` must satisfy the L0 hardware operation required fields: +`operationId`, `projectId`, `gatewaySessionId`, `resourceId`, `capabilityId`, +`requestedBy`, `status`, `environment`, `requestedAt`, and `updatedAt`. + +Each MVP `auditEvents[]` entry must satisfy the L0 audit event required fields +and additionally include `projectId`, `operationId`, and `outcome` so the audit +stream can be joined back to the operation without service-specific inference. + +Each MVP `traceEvents[]` entry must satisfy the L0 trace event required fields +and additionally include `operationId`. + +Each MVP `evidenceRecords[]` entry must satisfy the L0 evidence record required +fields and additionally include: + +- `metadata.traceId` +- `metadata.producedByAuditId` + +Agent automation chains must also include `agentSessionId` and +`workerSessionId` at the fixture root, on the hardware operation, on trace +events, and on evidence records. + +## Future Enhancements + +The first version intentionally avoids heavier guarantees. Later contracts may +add: + +- canonical chain hashing, previous-record links, and signatures +- attestation data from real hardware roots of trust +- retention policy, storage tier, and artifact redaction metadata +- richer OpenTelemetry or JSON-LD correlation fields +- multiple operations under one trace with explicit parent-child edges +- policy decision records for agent authorization and remediation + +Those additions must preserve the MVP identity bindings above. + +## Fixtures + +The MVP fixture set lives under `protocol/examples/evidence-chain`: + +- `fixtures/hardware-trusted-loop.json` +- `fixtures/agent-automation-loop.json` +- `artifacts/hardware-trusted-measurement.json` +- `artifacts/agent-automation-report.json` + +Validate the examples with: + +```sh +node scripts/validate-evidence-chain.mjs +``` diff --git a/protocol/examples/evidence-chain/README.md b/protocol/examples/evidence-chain/README.md new file mode 100644 index 00000000..9a4fde50 --- /dev/null +++ b/protocol/examples/evidence-chain/README.md @@ -0,0 +1,12 @@ +# Evidence Chain Examples + +These fixtures model the minimum MVP chain between hardware operations, audit +events, trace events, and evidence records. + +- `fixtures/hardware-trusted-loop.json` covers the hardware trusted closed loop. +- `fixtures/agent-automation-loop.json` covers the agent automation closed loop. +- `artifacts/` contains small content-addressed files referenced by the + evidence records. + +Run `node scripts/validate-evidence-chain.mjs` from the repository root to +verify field completeness, cross-record references, and artifact checksums. diff --git a/protocol/examples/evidence-chain/artifacts/agent-automation-report.json b/protocol/examples/evidence-chain/artifacts/agent-automation-report.json new file mode 100644 index 00000000..6a3a05e2 --- /dev/null +++ b/protocol/examples/evidence-chain/artifacts/agent-automation-report.json @@ -0,0 +1,13 @@ +{ + "format": "hwlab.agent-report.v1", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "result": "succeeded", + "steps": [ + "selected capability", + "ran firmware smoke command", + "persisted evidence" + ], + "createdAt": "2026-05-21T00:20:06.000Z" +} diff --git a/protocol/examples/evidence-chain/artifacts/hardware-trusted-measurement.json b/protocol/examples/evidence-chain/artifacts/hardware-trusted-measurement.json new file mode 100644 index 00000000..0bc68aac --- /dev/null +++ b/protocol/examples/evidence-chain/artifacts/hardware-trusted-measurement.json @@ -0,0 +1,15 @@ +{ + "format": "hwlab.measurement.v1", + "resourceId": "res_boxsim-alpha", + "capabilityId": "cap_gpio-measure", + "operationId": "op_hw-trusted-loop-001", + "samples": [ + { + "name": "gpio0", + "unit": "logic", + "value": 1, + "trusted": true + } + ], + "capturedAt": "2026-05-21T00:10:03.000Z" +} diff --git a/protocol/examples/evidence-chain/fixtures/agent-automation-loop.json b/protocol/examples/evidence-chain/fixtures/agent-automation-loop.json new file mode 100644 index 00000000..e94e6977 --- /dev/null +++ b/protocol/examples/evidence-chain/fixtures/agent-automation-loop.json @@ -0,0 +1,235 @@ +{ + "chainId": "echain_agent-auto-loop", + "schemaVersion": 1, + "scenario": "agent-automation-closed-loop", + "title": "Agent automation closed loop MVP", + "environment": "dev", + "projectId": "prj_evidence-chain", + "operationId": "op_agent-auto-loop-001", + "traceId": "trc_agent-auto-loop-001", + "gatewaySessionId": "gws_evidence-gateway-001", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "resourceId": "res_boxsim-beta", + "capabilityId": "cap_firmware-smoke", + "hardwareOperation": { + "operationId": "op_agent-auto-loop-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "resourceId": "res_boxsim-beta", + "capabilityId": "cap_firmware-smoke", + "requestedBy": "wkr_agent-auto-001", + "input": { + "command": "firmware-smoke", + "timeoutSeconds": 60 + }, + "output": { + "exitCode": 0, + "result": "passed" + }, + "status": "succeeded", + "environment": "dev", + "requestedAt": "2026-05-21T00:20:02.000Z", + "startedAt": "2026-05-21T00:20:03.000Z", + "completedAt": "2026-05-21T00:20:05.000Z", + "updatedAt": "2026-05-21T00:20:05.000Z" + }, + "auditEvents": [ + { + "auditId": "aud_agent-auto-session-started", + "traceId": "trc_agent-auto-loop-001", + "actorType": "user", + "actorId": "usr_alice", + "action": "agent.session.started", + "targetType": "agent_session", + "targetId": "ags_agent-auto-001", + "projectId": "prj_evidence-chain", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-agent-mgr", + "environment": "dev", + "outcome": "accepted", + "metadata": { + "agentSessionId": "ags_agent-auto-001", + "goal": "run firmware smoke and persist evidence" + }, + "occurredAt": "2026-05-21T00:20:00.000Z" + }, + { + "auditId": "aud_agent-auto-worker-started", + "traceId": "trc_agent-auto-loop-001", + "actorType": "agent", + "actorId": "agt_agent-auto-001", + "action": "worker.session.started", + "targetType": "worker_session", + "targetId": "wks_agent-auto-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "outcome": "accepted", + "metadata": { + "agentSessionId": "ags_agent-auto-001" + }, + "occurredAt": "2026-05-21T00:20:01.000Z" + }, + { + "auditId": "aud_agent-auto-operation-requested", + "traceId": "trc_agent-auto-loop-001", + "actorType": "worker", + "actorId": "wkr_agent-auto-001", + "action": "hardware.operation.requested", + "targetType": "hardware_operation", + "targetId": "op_agent-auto-loop-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "outcome": "accepted", + "metadata": { + "agentSessionId": "ags_agent-auto-001", + "capabilityId": "cap_firmware-smoke" + }, + "occurredAt": "2026-05-21T00:20:02.000Z" + }, + { + "auditId": "aud_agent-auto-operation-succeeded", + "traceId": "trc_agent-auto-loop-001", + "actorType": "service", + "actorId": "svc_hwlab-box-simu", + "action": "hardware.operation.succeeded", + "targetType": "hardware_operation", + "targetId": "op_agent-auto-loop-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-box-simu", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "agentSessionId": "ags_agent-auto-001", + "exitCode": 0 + }, + "occurredAt": "2026-05-21T00:20:05.000Z" + }, + { + "auditId": "aud_agent-auto-evidence-recorded", + "traceId": "trc_agent-auto-loop-001", + "actorType": "worker", + "actorId": "wkr_agent-auto-001", + "action": "evidence.recorded", + "targetType": "evidence_record", + "targetId": "evd_agent-auto-report", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "agentSessionId": "ags_agent-auto-001", + "evidenceId": "evd_agent-auto-report", + "evidenceKind": "report" + }, + "occurredAt": "2026-05-21T00:20:06.000Z" + } + ], + "traceEvents": [ + { + "traceEventId": "tev_agent-auto-goal", + "traceId": "trc_agent-auto-loop-001", + "projectId": "prj_evidence-chain", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-agent-mgr", + "level": "info", + "message": "Agent session accepted automation goal", + "environment": "dev", + "metadata": { + "auditId": "aud_agent-auto-session-started" + }, + "occurredAt": "2026-05-21T00:20:00.000Z" + }, + { + "traceEventId": "tev_agent-auto-worker", + "traceId": "trc_agent-auto-loop-001", + "projectId": "prj_evidence-chain", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-agent-worker", + "level": "info", + "message": "Worker requested firmware smoke operation", + "environment": "dev", + "metadata": { + "auditId": "aud_agent-auto-operation-requested" + }, + "occurredAt": "2026-05-21T00:20:02.000Z" + }, + { + "traceEventId": "tev_agent-auto-operation", + "traceId": "trc_agent-auto-loop-001", + "projectId": "prj_evidence-chain", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-box-simu", + "level": "info", + "message": "Firmware smoke operation completed", + "environment": "dev", + "metadata": { + "auditId": "aud_agent-auto-operation-succeeded" + }, + "occurredAt": "2026-05-21T00:20:05.000Z" + }, + { + "traceEventId": "tev_agent-auto-evidence", + "traceId": "trc_agent-auto-loop-001", + "projectId": "prj_evidence-chain", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "operationId": "op_agent-auto-loop-001", + "serviceId": "hwlab-agent-worker", + "level": "info", + "message": "Agent report evidence persisted", + "environment": "dev", + "metadata": { + "auditId": "aud_agent-auto-evidence-recorded", + "evidenceId": "evd_agent-auto-report" + }, + "occurredAt": "2026-05-21T00:20:06.000Z" + } + ], + "evidenceRecords": [ + { + "evidenceId": "evd_agent-auto-report", + "projectId": "prj_evidence-chain", + "operationId": "op_agent-auto-loop-001", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "kind": "report", + "uri": "protocol/examples/evidence-chain/artifacts/agent-automation-report.json", + "mimeType": "application/json", + "sha256": "2fc63687cd3170c9cf34cd650aaedfb84d3fe75683ae969ae286230ce3b425a7", + "sizeBytes": 340, + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "metadata": { + "traceId": "trc_agent-auto-loop-001", + "producedByAuditId": "aud_agent-auto-evidence-recorded", + "agentSessionId": "ags_agent-auto-001", + "workerSessionId": "wks_agent-auto-001", + "producerServiceId": "hwlab-agent-worker" + }, + "createdAt": "2026-05-21T00:20:06.000Z" + } + ] +} diff --git a/protocol/examples/evidence-chain/fixtures/hardware-trusted-loop.json b/protocol/examples/evidence-chain/fixtures/hardware-trusted-loop.json new file mode 100644 index 00000000..b90fa87d --- /dev/null +++ b/protocol/examples/evidence-chain/fixtures/hardware-trusted-loop.json @@ -0,0 +1,182 @@ +{ + "chainId": "echain_hw-trusted-loop", + "schemaVersion": 1, + "scenario": "hardware-trusted-closed-loop", + "title": "Hardware trusted closed loop MVP", + "environment": "dev", + "projectId": "prj_evidence-chain", + "operationId": "op_hw-trusted-loop-001", + "traceId": "trc_hw-trusted-loop-001", + "gatewaySessionId": "gws_evidence-gateway-001", + "resourceId": "res_boxsim-alpha", + "capabilityId": "cap_gpio-measure", + "hardwareOperation": { + "operationId": "op_hw-trusted-loop-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "resourceId": "res_boxsim-alpha", + "capabilityId": "cap_gpio-measure", + "requestedBy": "usr_alice", + "input": { + "command": "measure", + "port": "gpio0" + }, + "output": { + "sampleCount": 1, + "result": "logic-high" + }, + "status": "succeeded", + "environment": "dev", + "requestedAt": "2026-05-21T00:10:00.000Z", + "startedAt": "2026-05-21T00:10:01.000Z", + "completedAt": "2026-05-21T00:10:03.000Z", + "updatedAt": "2026-05-21T00:10:03.000Z" + }, + "auditEvents": [ + { + "auditId": "aud_hw-trusted-requested", + "traceId": "trc_hw-trusted-loop-001", + "actorType": "user", + "actorId": "usr_alice", + "action": "hardware.operation.requested", + "targetType": "hardware_operation", + "targetId": "op_hw-trusted-loop-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "operationId": "op_hw-trusted-loop-001", + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "outcome": "accepted", + "metadata": { + "requestId": "req_hw-trusted-001" + }, + "occurredAt": "2026-05-21T00:10:00.000Z" + }, + { + "auditId": "aud_hw-trusted-dispatched", + "traceId": "trc_hw-trusted-loop-001", + "actorType": "service", + "actorId": "svc_hwlab-gateway-simu", + "action": "hardware.operation.dispatched", + "targetType": "hardware_operation", + "targetId": "op_hw-trusted-loop-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "operationId": "op_hw-trusted-loop-001", + "serviceId": "hwlab-gateway-simu", + "environment": "dev", + "outcome": "accepted", + "metadata": { + "resourceId": "res_boxsim-alpha", + "capabilityId": "cap_gpio-measure" + }, + "occurredAt": "2026-05-21T00:10:01.000Z" + }, + { + "auditId": "aud_hw-trusted-succeeded", + "traceId": "trc_hw-trusted-loop-001", + "actorType": "service", + "actorId": "svc_hwlab-box-simu", + "action": "hardware.operation.succeeded", + "targetType": "hardware_operation", + "targetId": "op_hw-trusted-loop-001", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "operationId": "op_hw-trusted-loop-001", + "serviceId": "hwlab-box-simu", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "sampleCount": 1 + }, + "occurredAt": "2026-05-21T00:10:03.000Z" + }, + { + "auditId": "aud_hw-trusted-evidence-recorded", + "traceId": "trc_hw-trusted-loop-001", + "actorType": "service", + "actorId": "svc_hwlab-cloud-api", + "action": "evidence.recorded", + "targetType": "evidence_record", + "targetId": "evd_hw-trusted-measurement", + "projectId": "prj_evidence-chain", + "gatewaySessionId": "gws_evidence-gateway-001", + "operationId": "op_hw-trusted-loop-001", + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "outcome": "succeeded", + "metadata": { + "evidenceId": "evd_hw-trusted-measurement", + "evidenceKind": "measurement" + }, + "occurredAt": "2026-05-21T00:10:04.000Z" + } + ], + "traceEvents": [ + { + "traceEventId": "tev_hw-trusted-accepted", + "traceId": "trc_hw-trusted-loop-001", + "projectId": "prj_evidence-chain", + "operationId": "op_hw-trusted-loop-001", + "serviceId": "hwlab-cloud-api", + "level": "info", + "message": "Hardware operation accepted", + "environment": "dev", + "metadata": { + "auditId": "aud_hw-trusted-requested" + }, + "occurredAt": "2026-05-21T00:10:00.000Z" + }, + { + "traceEventId": "tev_hw-trusted-dispatched", + "traceId": "trc_hw-trusted-loop-001", + "projectId": "prj_evidence-chain", + "operationId": "op_hw-trusted-loop-001", + "serviceId": "hwlab-gateway-simu", + "level": "info", + "message": "Gateway dispatched trusted measurement", + "environment": "dev", + "metadata": { + "auditId": "aud_hw-trusted-dispatched" + }, + "occurredAt": "2026-05-21T00:10:01.000Z" + }, + { + "traceEventId": "tev_hw-trusted-evidence", + "traceId": "trc_hw-trusted-loop-001", + "projectId": "prj_evidence-chain", + "operationId": "op_hw-trusted-loop-001", + "serviceId": "hwlab-cloud-api", + "level": "info", + "message": "Measurement evidence persisted", + "environment": "dev", + "metadata": { + "auditId": "aud_hw-trusted-evidence-recorded", + "evidenceId": "evd_hw-trusted-measurement" + }, + "occurredAt": "2026-05-21T00:10:04.000Z" + } + ], + "evidenceRecords": [ + { + "evidenceId": "evd_hw-trusted-measurement", + "projectId": "prj_evidence-chain", + "operationId": "op_hw-trusted-loop-001", + "kind": "measurement", + "uri": "protocol/examples/evidence-chain/artifacts/hardware-trusted-measurement.json", + "mimeType": "application/json", + "sha256": "da1bb981f0a966e6906bfdf6a0387180617c8f891c131d7bebf9e19b14989a99", + "sizeBytes": 318, + "serviceId": "hwlab-cloud-api", + "environment": "dev", + "metadata": { + "traceId": "trc_hw-trusted-loop-001", + "producedByAuditId": "aud_hw-trusted-evidence-recorded", + "producerServiceId": "hwlab-box-simu", + "resourceId": "res_boxsim-alpha", + "capabilityId": "cap_gpio-measure" + }, + "createdAt": "2026-05-21T00:10:04.000Z" + } + ] +} diff --git a/scripts/validate-evidence-chain.mjs b/scripts/validate-evidence-chain.mjs new file mode 100644 index 00000000..6060b944 --- /dev/null +++ b/scripts/validate-evidence-chain.mjs @@ -0,0 +1,376 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixturesDir = "protocol/examples/evidence-chain/fixtures"; + +const idPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/; +const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/; +const sha256Pattern = /^[a-f0-9]{64}$/; + +const rootRequired = [ + "chainId", + "schemaVersion", + "scenario", + "projectId", + "operationId", + "traceId", + "environment", + "hardwareOperation", + "auditEvents", + "traceEvents", + "evidenceRecords" +]; + +const hardwareOperationRequired = [ + "operationId", + "projectId", + "gatewaySessionId", + "resourceId", + "capabilityId", + "requestedBy", + "status", + "environment", + "requestedAt", + "updatedAt" +]; + +const auditEventRequired = [ + "auditId", + "traceId", + "actorType", + "actorId", + "action", + "targetType", + "targetId", + "serviceId", + "environment", + "occurredAt", + "projectId", + "operationId", + "outcome" +]; + +const traceEventRequired = [ + "traceEventId", + "traceId", + "projectId", + "serviceId", + "level", + "message", + "environment", + "occurredAt", + "operationId" +]; + +const evidenceRecordRequired = [ + "evidenceId", + "projectId", + "operationId", + "kind", + "uri", + "sha256", + "serviceId", + "environment", + "createdAt" +]; + +const operationStatuses = new Set([ + "requested", + "accepted", + "queued", + "running", + "succeeded", + "failed", + "rejected", + "canceled", + "timed_out" +]); + +const actorTypes = new Set(["user", "service", "agent", "worker", "system"]); +const auditOutcomes = new Set(["accepted", "succeeded", "failed", "rejected", "canceled"]); +const traceLevels = new Set(["debug", "info", "warn", "error"]); +const evidenceKinds = new Set(["log", "screenshot", "measurement", "artifact", "report", "trace"]); +const scenarios = new Set(["hardware-trusted-closed-loop", "agent-automation-closed-loop"]); + +function assertObject(value, label) { + assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); +} + +function assertRequired(value, fields, label) { + for (const field of fields) { + assert.ok(Object.hasOwn(value, field), `${label} missing ${field}`); + } +} + +function assertId(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + assert.match(value, idPattern, `${label} must match HWLAB id format`); +} + +function assertTimestamp(value, label) { + assert.equal(typeof value, "string", `${label} must be a string`); + assert.ok(!Number.isNaN(Date.parse(value)), `${label} must be a parseable timestamp`); +} + +function assertServiceId(value, label) { + assert.ok(SERVICE_IDS.includes(value), `${label} must be a frozen service id`); +} + +function assertUnique(values, label) { + assert.equal(new Set(values).size, values.length, `${label} must be unique`); +} + +function assertSame(actual, expected, label) { + assert.equal(actual, expected, `${label} must match ${expected}`); +} + +async function parseFixtures() { + const entries = await readdir(path.join(repoRoot, fixturesDir), { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".json")) + .map((entry) => path.join(fixturesDir, entry.name)) + .sort(); + + const fixtures = []; + for (const relativePath of files) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + fixtures.push({ relativePath, doc: JSON.parse(raw) }); + } + return fixtures; +} + +async function assertArtifact(record, label) { + assert.match(record.sha256, sha256Pattern, `${label}.sha256 must be lowercase sha256`); + assert.equal(typeof record.uri, "string", `${label}.uri must be a string`); + + const artifactPath = path.resolve(repoRoot, record.uri); + assert.ok( + artifactPath.startsWith(`${repoRoot}${path.sep}`), + `${label}.uri must point inside the repository` + ); + + const content = await readFile(artifactPath); + const digest = createHash("sha256").update(content).digest("hex"); + assert.equal(digest, record.sha256, `${label}.sha256 must match ${record.uri}`); + + if (Object.hasOwn(record, "sizeBytes")) { + assert.equal(record.sizeBytes, content.byteLength, `${label}.sizeBytes must match ${record.uri}`); + } +} + +function validateHardwareOperation(chain, operation) { + assertObject(operation, `${chain.chainId}.hardwareOperation`); + assertRequired(operation, hardwareOperationRequired, `${chain.chainId}.hardwareOperation`); + + for (const field of [ + "operationId", + "projectId", + "gatewaySessionId", + "resourceId", + "capabilityId", + "requestedBy" + ]) { + assertId(operation[field], `${chain.chainId}.hardwareOperation.${field}`); + } + + assertSame(operation.operationId, chain.operationId, `${chain.chainId}.hardwareOperation.operationId`); + assertSame(operation.projectId, chain.projectId, `${chain.chainId}.hardwareOperation.projectId`); + assertSame(operation.environment, chain.environment, `${chain.chainId}.hardwareOperation.environment`); + assert.ok(operationStatuses.has(operation.status), `${chain.chainId}.hardwareOperation.status is invalid`); + assertTimestamp(operation.requestedAt, `${chain.chainId}.hardwareOperation.requestedAt`); + assertTimestamp(operation.updatedAt, `${chain.chainId}.hardwareOperation.updatedAt`); + + for (const field of ["startedAt", "completedAt"]) { + if (Object.hasOwn(operation, field)) { + assertTimestamp(operation[field], `${chain.chainId}.hardwareOperation.${field}`); + } + } + + if (chain.agentSessionId) { + assertSame(operation.agentSessionId, chain.agentSessionId, `${chain.chainId}.hardwareOperation.agentSessionId`); + } + if (chain.workerSessionId) { + assertSame(operation.workerSessionId, chain.workerSessionId, `${chain.chainId}.hardwareOperation.workerSessionId`); + } +} + +function validateAuditEvents(chain, auditEvents) { + assert.ok(Array.isArray(auditEvents), `${chain.chainId}.auditEvents must be an array`); + assert.ok(auditEvents.length >= 2, `${chain.chainId}.auditEvents must include operation and evidence audit events`); + assertUnique( + auditEvents.map((event) => event.auditId), + `${chain.chainId}.auditId values` + ); + + for (const [index, event] of auditEvents.entries()) { + const label = `${chain.chainId}.auditEvents[${index}]`; + assertObject(event, label); + assertRequired(event, auditEventRequired, label); + + for (const field of ["auditId", "traceId", "actorId", "targetId", "projectId", "operationId"]) { + assertId(event[field], `${label}.${field}`); + } + + assertSame(event.traceId, chain.traceId, `${label}.traceId`); + assertSame(event.projectId, chain.projectId, `${label}.projectId`); + assertSame(event.operationId, chain.operationId, `${label}.operationId`); + assertSame(event.environment, chain.environment, `${label}.environment`); + assert.ok(actorTypes.has(event.actorType), `${label}.actorType is invalid`); + assert.match(event.action, actionPattern, `${label}.action must be dotted`); + assert.equal(typeof event.targetType, "string", `${label}.targetType must be a string`); + assertServiceId(event.serviceId, `${label}.serviceId`); + assert.ok(auditOutcomes.has(event.outcome), `${label}.outcome is invalid`); + assertTimestamp(event.occurredAt, `${label}.occurredAt`); + + if (Object.hasOwn(event, "gatewaySessionId")) { + assertSame(event.gatewaySessionId, chain.gatewaySessionId, `${label}.gatewaySessionId`); + } + if (chain.workerSessionId && Object.hasOwn(event, "workerSessionId")) { + assertSame(event.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`); + } + } + + assert.ok( + auditEvents.some((event) => event.targetType === "hardware_operation" && event.targetId === chain.operationId), + `${chain.chainId} must audit the hardware operation` + ); +} + +function validateTraceEvents(chain, traceEvents, auditIds) { + assert.ok(Array.isArray(traceEvents), `${chain.chainId}.traceEvents must be an array`); + assert.ok(traceEvents.length >= 1, `${chain.chainId}.traceEvents must not be empty`); + assertUnique( + traceEvents.map((event) => event.traceEventId), + `${chain.chainId}.traceEventId values` + ); + + for (const [index, event] of traceEvents.entries()) { + const label = `${chain.chainId}.traceEvents[${index}]`; + assertObject(event, label); + assertRequired(event, traceEventRequired, label); + + for (const field of ["traceEventId", "traceId", "projectId", "operationId"]) { + assertId(event[field], `${label}.${field}`); + } + + assertSame(event.traceId, chain.traceId, `${label}.traceId`); + assertSame(event.projectId, chain.projectId, `${label}.projectId`); + assertSame(event.operationId, chain.operationId, `${label}.operationId`); + assertSame(event.environment, chain.environment, `${label}.environment`); + assertServiceId(event.serviceId, `${label}.serviceId`); + assert.ok(traceLevels.has(event.level), `${label}.level is invalid`); + assert.equal(typeof event.message, "string", `${label}.message must be a string`); + assert.ok(event.message.length > 0, `${label}.message must not be empty`); + assertTimestamp(event.occurredAt, `${label}.occurredAt`); + + if (chain.agentSessionId) { + assertSame(event.agentSessionId, chain.agentSessionId, `${label}.agentSessionId`); + } + if (chain.workerSessionId) { + assertSame(event.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`); + } + if (event.metadata?.auditId) { + assert.ok(auditIds.has(event.metadata.auditId), `${label}.metadata.auditId must reference same-chain audit`); + } + } +} + +async function validateEvidenceRecords(chain, evidenceRecords, auditEvents) { + assert.ok(Array.isArray(evidenceRecords), `${chain.chainId}.evidenceRecords must be an array`); + assert.ok(evidenceRecords.length >= 1, `${chain.chainId}.evidenceRecords must not be empty`); + assertUnique( + evidenceRecords.map((record) => record.evidenceId), + `${chain.chainId}.evidenceId values` + ); + + const auditsById = new Map(auditEvents.map((event) => [event.auditId, event])); + + for (const [index, record] of evidenceRecords.entries()) { + const label = `${chain.chainId}.evidenceRecords[${index}]`; + assertObject(record, label); + assertRequired(record, evidenceRecordRequired, label); + + for (const field of ["evidenceId", "projectId", "operationId"]) { + assertId(record[field], `${label}.${field}`); + } + + assertSame(record.projectId, chain.projectId, `${label}.projectId`); + assertSame(record.operationId, chain.operationId, `${label}.operationId`); + assertSame(record.environment, chain.environment, `${label}.environment`); + assert.ok(evidenceKinds.has(record.kind), `${label}.kind is invalid`); + assertServiceId(record.serviceId, `${label}.serviceId`); + assertTimestamp(record.createdAt, `${label}.createdAt`); + assertObject(record.metadata, `${label}.metadata`); + assertSame(record.metadata.traceId, chain.traceId, `${label}.metadata.traceId`); + assertId(record.metadata.producedByAuditId, `${label}.metadata.producedByAuditId`); + + const producerAudit = auditsById.get(record.metadata.producedByAuditId); + assert.ok(producerAudit, `${label}.metadata.producedByAuditId must reference same-chain audit`); + assert.equal(producerAudit.targetType, "evidence_record", `${label} producer audit must target evidence_record`); + assert.equal(producerAudit.targetId, record.evidenceId, `${label} producer audit must target this evidenceId`); + + if (chain.agentSessionId) { + assertSame(record.agentSessionId, chain.agentSessionId, `${label}.agentSessionId`); + assertSame(record.metadata.agentSessionId, chain.agentSessionId, `${label}.metadata.agentSessionId`); + } + if (chain.workerSessionId) { + assertSame(record.workerSessionId, chain.workerSessionId, `${label}.workerSessionId`); + assertSame(record.metadata.workerSessionId, chain.workerSessionId, `${label}.metadata.workerSessionId`); + } + + await assertArtifact(record, label); + } +} + +async function validateFixture({ relativePath, doc }) { + assertObject(doc, relativePath); + assertRequired(doc, rootRequired, relativePath); + + for (const field of ["chainId", "projectId", "operationId", "traceId"]) { + assertId(doc[field], `${relativePath}.${field}`); + } + + assert.equal(doc.schemaVersion, 1, `${relativePath}.schemaVersion must be 1`); + assert.ok(scenarios.has(doc.scenario), `${relativePath}.scenario is not recognized`); + assert.equal(doc.environment, ENVIRONMENT_DEV, `${relativePath}.environment must be dev`); + + for (const field of ["gatewaySessionId", "resourceId", "capabilityId", "agentSessionId", "workerSessionId"]) { + if (Object.hasOwn(doc, field)) { + assertId(doc[field], `${relativePath}.${field}`); + } + } + + if (doc.scenario === "agent-automation-closed-loop") { + assertId(doc.agentSessionId, `${relativePath}.agentSessionId`); + assertId(doc.workerSessionId, `${relativePath}.workerSessionId`); + } + + validateHardwareOperation(doc, doc.hardwareOperation); + validateAuditEvents(doc, doc.auditEvents); + + const auditIds = new Set(doc.auditEvents.map((event) => event.auditId)); + validateTraceEvents(doc, doc.traceEvents, auditIds); + await validateEvidenceRecords(doc, doc.evidenceRecords, doc.auditEvents); +} + +const fixtures = await parseFixtures(); + +assert.ok(fixtures.length >= 2, "expected at least two MVP evidence chain fixtures"); + +const scenarioSet = new Set(fixtures.map(({ doc }) => doc.scenario)); +for (const scenario of scenarios) { + assert.ok(scenarioSet.has(scenario), `missing ${scenario} fixture`); +} + +for (const fixture of fixtures) { + await validateFixture(fixture); +} + +const evidenceCount = fixtures.reduce((count, { doc }) => count + doc.evidenceRecords.length, 0); +console.log(`validated ${fixtures.length} evidence chain fixtures and ${evidenceCount} evidence records`);