From e039deea10ab66dd3c7b4b926a6a99bdc130db80 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 15:03:49 +0000 Subject: [PATCH] docs: add M0 contract audit --- docs/m0-contract-audit.md | 109 +++++++ .../examples/m0-contract/audit-event.json | 22 ++ .../m0-contract/capability-topology.json | 224 +++++++++++++++ .../m0-contract/deploy-manifest.dev.json | 126 +++++++++ .../examples/m0-contract/json-rpc-error.json | 20 ++ .../m0-contract/json-rpc-request.json | 26 ++ .../m0-contract/json-rpc-response.json | 14 + .../examples/m0-contract/service-ids.json | 19 ++ scripts/validate-m0-contract.mjs | 265 ++++++++++++++++++ 9 files changed, 825 insertions(+) create mode 100644 docs/m0-contract-audit.md create mode 100644 protocol/examples/m0-contract/audit-event.json create mode 100644 protocol/examples/m0-contract/capability-topology.json create mode 100644 protocol/examples/m0-contract/deploy-manifest.dev.json create mode 100644 protocol/examples/m0-contract/json-rpc-error.json create mode 100644 protocol/examples/m0-contract/json-rpc-request.json create mode 100644 protocol/examples/m0-contract/json-rpc-response.json create mode 100644 protocol/examples/m0-contract/service-ids.json create mode 100644 scripts/validate-m0-contract.mjs diff --git a/docs/m0-contract-audit.md b/docs/m0-contract-audit.md new file mode 100644 index 00000000..858b4618 --- /dev/null +++ b/docs/m0-contract-audit.md @@ -0,0 +1,109 @@ +# M0 Contract Audit + +This audit independently reviews whether the L0 skeleton is enough for M0 +contract work under the issue constraints supplied for pikasTech/HWLAB#19 and +the referenced #7/#11/#12/#16 guardrails. + +Applied constraints: + +- MVP acceptance is DEV only at `http://74.48.78.17:6667`. +- PROD is reserved and must not be deployed for this audit. +- HWLAB runtime services must keep their frozen service IDs and must not be + replaced by UniDesk services. +- This audit does not modify L1-L6 service implementation directories and does + not create or claim the L5 deploy manifest. + +## Audit Result + +Status: conditionally passes M0 after this audit patch. + +The repository already contains the L0 contract skeleton needed by parallel +MVP work: + +- Root README freezes MVP scope, DEV endpoint, service IDs, and repository + layout. +- `internal/protocol/index.mjs` centralizes DEV endpoint, frozen service IDs, + table names, JSON-RPC version, and HWLAB error codes. +- `protocol/*.md` documents JSON-RPC envelopes, error codes, audit fields, + capability model, topology constraints, and the sequence-level MVP flow. +- `protocol/schemas/*.json` names the key protocol records and uses + draft-2020-12 JSON Schema shape files. +- `deploy/deploy.schema.json` names the deploy manifest fields and reuses the + frozen service ID list. +- `scripts/validate-contract.mjs` already checks protocol constants against + the common and deploy schemas. + +This patch adds M0 examples under `protocol/examples/m0-contract/` and a +lightweight executable check in `scripts/validate-m0-contract.mjs` so the audit +is not document-only. + +## Coverage Matrix + +| Contract area | Current L0 source | Added M0 sample/check | Result | +| --- | --- | --- | --- | +| Frozen service IDs | `README.md`, `internal/protocol/index.mjs`, `protocol/schemas/common.json`, `deploy/deploy.schema.json` | `service-ids.json`, deploy sample, exact list comparison | Covered | +| JSON-RPC request/response | `protocol/json-rpc.md`, `json-rpc-envelope.schema.json`, runtime validators | `json-rpc-request.json`, `json-rpc-response.json` | Covered | +| Error envelope | `protocol/errors.md`, runtime `ERROR_CODES` | `json-rpc-error.json`, reserved code and stable `error.data` fields | Covered | +| Audit fields | `protocol/audit.md`, `audit-event.schema.json` | `audit-event.json`, required field and ID checks | Covered | +| Capability and topology | `protocol/capabilities.md`, `protocol/topology.md`, related schemas | `capability-topology.json`, cross-reference checks | Covered for M0 | +| Deploy schema fields | `deploy/deploy.schema.json`, `deploy/README.md` | `deploy-manifest.dev.json`, DEV-only example checks | Covered without L5 manifest changes | + +## Rework Risk Register + +Blocker: persisted schema naming drift before L1/L4/L6 integration. +`internal/db/migrations/0001_cloud_core_skeleton.sql` uses placeholder columns +such as `request_id`, `actor`, `source`, `operation`, `result`, `operation_type`, +and `evidence_type`, while the protocol schemas use `auditId`, `traceId`, +`actorType`, `serviceId`, `action`, `outcome`, `capabilityId`, and `kind`. +Before services persist real records, L1 must either align the migration with +the protocol names or provide an explicit mapping layer that is tested. + +Blocker: schema files are parseable but not fully enforced at runtime. +The existing validators and the new M0 check intentionally avoid heavyweight +dependencies and do not evaluate all JSON Schema references. L1-L6 service +entry points need a shared validator such as Ajv or a generated equivalent +before accepting untrusted API, gateway, or agent payloads. + +Blocker: topology constraints are partly prose-only. +M0 can verify that examples cross-reference known resources, capabilities, and +sessions. M1-M3 need a real topology validator for cycle detection, active +resource-group exclusivity, patch-panel override behavior, and gateway/box +ownership. + +Blocker: deploy schema allows future `prod` profile shape. +That is acceptable for L0 compatibility, but L5 must keep MVP execution DEV-only +and reject any real PROD deploy path until a later milestone explicitly changes +the acceptance target. + +Enhancement: RPC method catalog is not yet complete. +M0 freezes envelope shape, method naming, and representative +`hardware.operation.request`. Later milestones should add a method registry with +params/result schemas for project, gateway, patch-panel, worker, evidence, and +cleanup flows. + +Enhancement: capability constraints are generic JSON objects. +This keeps M0 flexible, but hardware-facing work should add typed constraint +profiles for common operations such as voltage ranges, serial settings, +firmware flashing, and measurement tolerances. + +Enhancement: evidence integrity is structural only. +The sample checks SHA-256 format but does not hash referenced artifacts. M4/M5 +should verify artifact existence, size, content hash, and retention policy. + +Enhancement: service ID drift checks are duplicated. +Current validation compares runtime constants with schema enums. A later cleanup +could generate schema enums and docs from one registry. + +## Validation + +Run: + +```sh +npm run check +node --check scripts/validate-m0-contract.mjs +node scripts/validate-m0-contract.mjs +``` + +The M0 script parses all JSON files in `protocol/examples/m0-contract/`, checks +the audit Markdown headings, and verifies the representative cross-document +invariants needed for M0. diff --git a/protocol/examples/m0-contract/audit-event.json b/protocol/examples/m0-contract/audit-event.json new file mode 100644 index 00000000..90d15a0d --- /dev/null +++ b/protocol/examples/m0-contract/audit-event.json @@ -0,0 +1,22 @@ +{ + "auditId": "aud_01J00000000000000000000010", + "traceId": "trc_01J00000000000000000000010", + "actorType": "worker", + "actorId": "wrk_01J00000000000000000000010", + "action": "hardware.operation.succeeded", + "targetType": "hardware_operation", + "targetId": "op_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "workerSessionId": "wks_01J00000000000000000000010", + "operationId": "op_01J00000000000000000000010", + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "outcome": "succeeded", + "reason": "operation_completed", + "metadata": { + "capabilityId": "cap_01J00000000000000000000010", + "evidenceId": "evd_01J00000000000000000000010" + }, + "occurredAt": "2026-05-21T00:00:05.000Z" +} diff --git a/protocol/examples/m0-contract/capability-topology.json b/protocol/examples/m0-contract/capability-topology.json new file mode 100644 index 00000000..8823c14d --- /dev/null +++ b/protocol/examples/m0-contract/capability-topology.json @@ -0,0 +1,224 @@ +{ + "contract": "m0-capability-topology", + "project": { + "projectId": "prj_01J00000000000000000000010", + "name": "M0 Contract Project", + "status": "active", + "environment": "dev", + "createdBy": "usr_01J00000000000000000000010", + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + "gatewaySession": { + "gatewaySessionId": "gws_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "serviceId": "hwlab-gateway-simu", + "gatewayId": "gtw_01J00000000000000000000010", + "endpoint": "http://127.0.0.1:7101", + "status": "connected", + "environment": "dev", + "startedAt": "2026-05-21T00:00:00.000Z", + "lastSeenAt": "2026-05-21T00:00:02.000Z", + "labels": { + "mode": "simulated" + } + }, + "resources": [ + { + "resourceId": "res_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "boxId": "box_01J00000000000000000000010", + "resourceType": "power_supply", + "name": "simulated power supply", + "state": "available", + "environment": "dev", + "metadata": { + "ports": ["out0"] + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + { + "resourceId": "res_01J00000000000000000000011", + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "boxId": "box_01J00000000000000000000010", + "resourceType": "board", + "name": "simulated dut board", + "state": "reserved", + "environment": "dev", + "metadata": { + "ports": ["vin", "uart0"] + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "capabilities": [ + { + "capabilityId": "cap_01J00000000000000000000010", + "resourceId": "res_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "name": "power.cycle", + "description": "Cycle simulated DUT power through the gateway.", + "direction": "bidirectional", + "valueType": "object", + "constraints": { + "holdMs": { + "minimum": 100, + "maximum": 5000 + } + }, + "mutatesState": true, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + { + "capabilityId": "cap_01J00000000000000000000011", + "resourceId": "res_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "name": "power.set_voltage", + "description": "Set simulated output voltage.", + "direction": "input", + "valueType": "number", + "unit": "V", + "constraints": { + "minimum": 0, + "maximum": 12 + }, + "mutatesState": true, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + } + ], + "wiringConfig": { + "wiringConfigId": "wir_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "name": "M0 simulated DUT power wiring", + "status": "active", + "connections": [ + { + "from": { + "resourceId": "res_01J00000000000000000000010", + "port": "out0", + "capabilityId": "cap_01J00000000000000000000011" + }, + "to": { + "resourceId": "res_01J00000000000000000000011", + "port": "vin" + }, + "mode": "exclusive" + } + ], + "constraints": { + "oneActiveConfigPerResourceGroup": true, + "cyclesAllowed": false + }, + "createdAt": "2026-05-21T00:00:00.000Z", + "updatedAt": "2026-05-21T00:00:00.000Z" + }, + "patchPanelStatus": { + "patchPanelStatusId": "pps_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "wiringConfigId": "wir_01J00000000000000000000010", + "serviceId": "hwlab-patch-panel", + "state": "active", + "environment": "dev", + "activeConnections": [ + { + "fromResourceId": "res_01J00000000000000000000010", + "fromPort": "out0", + "toResourceId": "res_01J00000000000000000000011", + "toPort": "vin" + } + ], + "observedAt": "2026-05-21T00:00:01.000Z", + "metadata": { + "connectionCount": 1 + } + }, + "agentSession": { + "agentSessionId": "ags_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "serviceId": "hwlab-agent-mgr", + "status": "active", + "environment": "dev", + "requestedBy": "usr_01J00000000000000000000010", + "goal": "Cycle DUT power and persist evidence.", + "startedAt": "2026-05-21T00:00:02.000Z", + "updatedAt": "2026-05-21T00:00:02.000Z" + }, + "workerSession": { + "workerSessionId": "wks_01J00000000000000000000010", + "agentSessionId": "ags_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "serviceId": "hwlab-agent-worker", + "gatewaySessionId": "gws_01J00000000000000000000010", + "status": "active", + "environment": "dev", + "startedAt": "2026-05-21T00:00:03.000Z", + "updatedAt": "2026-05-21T00:00:03.000Z" + }, + "hardwareOperation": { + "operationId": "op_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "agentSessionId": "ags_01J00000000000000000000010", + "workerSessionId": "wks_01J00000000000000000000010", + "resourceId": "res_01J00000000000000000000010", + "capabilityId": "cap_01J00000000000000000000010", + "requestedBy": "usr_01J00000000000000000000010", + "input": { + "cycle": true, + "holdMs": 250 + }, + "output": { + "powerState": "on" + }, + "status": "succeeded", + "environment": "dev", + "requestedAt": "2026-05-21T00:00:03.000Z", + "startedAt": "2026-05-21T00:00:04.000Z", + "completedAt": "2026-05-21T00:00:05.000Z", + "updatedAt": "2026-05-21T00:00:05.000Z" + }, + "traceEvents": [ + { + "traceEventId": "tev_01J00000000000000000000010", + "traceId": "trc_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "agentSessionId": "ags_01J00000000000000000000010", + "workerSessionId": "wks_01J00000000000000000000010", + "operationId": "op_01J00000000000000000000010", + "serviceId": "hwlab-agent-worker", + "level": "info", + "message": "hardware operation completed", + "environment": "dev", + "metadata": { + "status": "succeeded" + }, + "occurredAt": "2026-05-21T00:00:05.000Z" + } + ], + "evidenceRecord": { + "evidenceId": "evd_01J00000000000000000000010", + "projectId": "prj_01J00000000000000000000010", + "operationId": "op_01J00000000000000000000010", + "agentSessionId": "ags_01J00000000000000000000010", + "workerSessionId": "wks_01J00000000000000000000010", + "kind": "report", + "uri": "protocol/examples/m0-contract/evidence/power-cycle-report.md", + "mimeType": "text/markdown", + "sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "sizeBytes": 0, + "serviceId": "hwlab-agent-worker", + "environment": "dev", + "metadata": { + "source": "m0_contract_example" + }, + "createdAt": "2026-05-21T00:00:05.000Z" + } +} diff --git a/protocol/examples/m0-contract/deploy-manifest.dev.json b/protocol/examples/m0-contract/deploy-manifest.dev.json new file mode 100644 index 00000000..5f688ac1 --- /dev/null +++ b/protocol/examples/m0-contract/deploy-manifest.dev.json @@ -0,0 +1,126 @@ +{ + "manifestVersion": "v1", + "environment": "dev", + "commitId": "6509a35804ee97dd8d711bdde334c46ca37c55b9", + "namespace": "hwlab-dev", + "endpoint": "http://74.48.78.17:6667", + "profiles": { + "dev": { + "name": "dev", + "enabled": true, + "namespace": "hwlab-dev", + "endpoint": "http://74.48.78.17:6667", + "notes": "M0 contract example only; not a deploy manifest." + } + }, + "services": [ + { + "serviceId": "hwlab-cloud-api", + "image": "ghcr.io/pikastech/hwlab-cloud-api:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1, + "env": { + "HWLAB_ENV": "dev" + } + }, + { + "serviceId": "hwlab-cloud-web", + "image": "ghcr.io/pikastech/hwlab-cloud-web:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-agent-mgr", + "image": "ghcr.io/pikastech/hwlab-agent-mgr:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-agent-worker", + "image": "ghcr.io/pikastech/hwlab-agent-worker:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-gateway", + "image": "ghcr.io/pikastech/hwlab-gateway:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 0 + }, + { + "serviceId": "hwlab-gateway-simu", + "image": "ghcr.io/pikastech/hwlab-gateway-simu:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-box-simu", + "image": "ghcr.io/pikastech/hwlab-box-simu:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-patch-panel", + "image": "ghcr.io/pikastech/hwlab-patch-panel:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-router", + "image": "ghcr.io/pikastech/hwlab-router:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-tunnel-client", + "image": "ghcr.io/pikastech/hwlab-tunnel-client:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-edge-proxy", + "image": "ghcr.io/pikastech/hwlab-edge-proxy:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 1 + }, + { + "serviceId": "hwlab-cli", + "image": "ghcr.io/pikastech/hwlab-cli:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 0 + }, + { + "serviceId": "hwlab-agent-skills", + "image": "ghcr.io/pikastech/hwlab-agent-skills:6509a35", + "namespace": "hwlab-dev", + "healthPath": "/healthz", + "profile": "dev", + "replicas": 0 + } + ], + "createdAt": "2026-05-21T00:00:00.000Z" +} diff --git a/protocol/examples/m0-contract/json-rpc-error.json b/protocol/examples/m0-contract/json-rpc-error.json new file mode 100644 index 00000000..44c41ab3 --- /dev/null +++ b/protocol/examples/m0-contract/json-rpc-error.json @@ -0,0 +1,20 @@ +{ + "jsonrpc": "2.0", + "id": "req_01J00000000000000000000010", + "error": { + "code": -32020, + "message": "Requested hardware capability is unavailable", + "data": { + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "resourceId": "res_01J00000000000000000000010", + "capabilityId": "cap_01J00000000000000000000010", + "reason": "capability_unavailable" + } + }, + "meta": { + "traceId": "trc_01J00000000000000000000010", + "serviceId": "hwlab-gateway", + "environment": "dev" + } +} diff --git a/protocol/examples/m0-contract/json-rpc-request.json b/protocol/examples/m0-contract/json-rpc-request.json new file mode 100644 index 00000000..eb88478a --- /dev/null +++ b/protocol/examples/m0-contract/json-rpc-request.json @@ -0,0 +1,26 @@ +{ + "jsonrpc": "2.0", + "id": "req_01J00000000000000000000010", + "method": "hardware.operation.request", + "params": { + "projectId": "prj_01J00000000000000000000010", + "gatewaySessionId": "gws_01J00000000000000000000010", + "resourceId": "res_01J00000000000000000000010", + "capabilityId": "cap_01J00000000000000000000010", + "input": { + "cycle": true, + "holdMs": 250 + }, + "audit": { + "actorType": "user", + "actorId": "usr_01J00000000000000000000010", + "reason": "m0_contract_example" + } + }, + "meta": { + "traceId": "trc_01J00000000000000000000010", + "actorId": "usr_01J00000000000000000000010", + "serviceId": "hwlab-cloud-api", + "environment": "dev" + } +} diff --git a/protocol/examples/m0-contract/json-rpc-response.json b/protocol/examples/m0-contract/json-rpc-response.json new file mode 100644 index 00000000..1592246a --- /dev/null +++ b/protocol/examples/m0-contract/json-rpc-response.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": "req_01J00000000000000000000010", + "result": { + "accepted": true, + "operationId": "op_01J00000000000000000000010", + "status": "accepted" + }, + "meta": { + "traceId": "trc_01J00000000000000000000010", + "serviceId": "hwlab-agent-mgr", + "environment": "dev" + } +} diff --git a/protocol/examples/m0-contract/service-ids.json b/protocol/examples/m0-contract/service-ids.json new file mode 100644 index 00000000..df7f67b8 --- /dev/null +++ b/protocol/examples/m0-contract/service-ids.json @@ -0,0 +1,19 @@ +{ + "contract": "m0-service-ids", + "environment": "dev", + "serviceIds": [ + "hwlab-cloud-api", + "hwlab-cloud-web", + "hwlab-agent-mgr", + "hwlab-agent-worker", + "hwlab-gateway", + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-router", + "hwlab-tunnel-client", + "hwlab-edge-proxy", + "hwlab-cli", + "hwlab-agent-skills" + ] +} diff --git a/scripts/validate-m0-contract.mjs b/scripts/validate-m0-contract.mjs new file mode 100644 index 00000000..f33ec468 --- /dev/null +++ b/scripts/validate-m0-contract.mjs @@ -0,0 +1,265 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + DEV_ENDPOINT, + ENVIRONMENT_DEV, + ERROR_CODES, + SERVICE_IDS, + validateRequest, + validateResponse +} from "../internal/protocol/index.mjs"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const examplesDir = "protocol/examples/m0-contract"; +const auditDocPath = "docs/m0-contract-audit.md"; + +const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; +const idPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/; +const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/; + +async function readJSON(relativePath) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + return JSON.parse(raw); +} + +async function loadExamples() { + const entries = await readdir(path.join(repoRoot, examplesDir), { withFileTypes: true }); + const examples = new Map(); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".json")) { + continue; + } + const relativePath = path.join(examplesDir, entry.name); + examples.set(entry.name, await readJSON(relativePath)); + } + return examples; +} + +function assertUnique(name, values) { + assert.equal(new Set(values).size, values.length, `${name} must be unique`); +} + +function assertServiceId(serviceId, context) { + assert.ok(SERVICE_IDS.includes(serviceId), `${context} uses unknown serviceId ${serviceId}`); +} + +function assertDevEnvironment(value, context) { + assert.equal(value, ENVIRONMENT_DEV, `${context} must be dev`); +} + +function assertId(value, context) { + assert.equal(typeof value, "string", `${context} must be a string id`); + assert.match(value, idPattern, `${context} must match HWLAB id pattern`); +} + +function assertTimestamp(value, context) { + assert.equal(typeof value, "string", `${context} must be a timestamp`); + assert.match(value, timestampPattern, `${context} must be an RFC 3339 UTC timestamp`); +} + +function assertServiceIdsExample(doc) { + assert.equal(doc.contract, "m0-service-ids"); + assertDevEnvironment(doc.environment, "service-ids environment"); + assert.deepEqual(doc.serviceIds, SERVICE_IDS, "example service ids must match frozen runtime service ids"); + assertUnique("example service ids", doc.serviceIds); +} + +function assertJsonRpcExamples(request, response, errorEnvelope) { + validateRequest(request); + validateResponse(response); + validateResponse(errorEnvelope); + + assert.equal(response.id, request.id, "JSON-RPC response must echo request id"); + assert.equal(errorEnvelope.id, request.id, "JSON-RPC error must echo request id"); + assert.equal(response.meta.traceId, request.meta.traceId, "response must keep request traceId"); + assert.equal(errorEnvelope.meta.traceId, request.meta.traceId, "error must keep request traceId"); + assertServiceId(request.meta.serviceId, "request meta"); + assertServiceId(response.meta.serviceId, "response meta"); + assertServiceId(errorEnvelope.meta.serviceId, "error meta"); + assert.ok( + Object.values(ERROR_CODES).includes(errorEnvelope.error.code), + "error example must use a reserved JSON-RPC or HWLAB error code" + ); + for (const key of ["projectId", "gatewaySessionId", "resourceId", "capabilityId", "reason"]) { + assert.ok(Object.hasOwn(errorEnvelope.error.data, key), `error.data must include ${key}`); + } +} + +function assertAuditEvent(doc) { + for (const key of [ + "auditId", + "traceId", + "actorType", + "actorId", + "action", + "targetType", + "targetId", + "serviceId", + "environment", + "occurredAt" + ]) { + assert.ok(Object.hasOwn(doc, key), `audit event missing ${key}`); + } + for (const key of ["auditId", "traceId", "actorId", "targetId", "projectId", "gatewaySessionId", "workerSessionId", "operationId"]) { + assertId(doc[key], `audit event ${key}`); + } + assert.match(doc.action, actionPattern, "audit action must be dotted lower-case"); + assert.equal(doc.targetId, doc.operationId, "operation audit targetId must equal operationId"); + assertServiceId(doc.serviceId, "audit event"); + assertDevEnvironment(doc.environment, "audit event environment"); + assertTimestamp(doc.occurredAt, "audit occurredAt"); +} + +function assertCapabilityTopology(doc) { + assert.equal(doc.contract, "m0-capability-topology"); + assertDevEnvironment(doc.project.environment, "project environment"); + assertId(doc.project.projectId, "projectId"); + assert.equal(doc.gatewaySession.projectId, doc.project.projectId, "gateway session projectId must match project"); + assert.ok(["hwlab-gateway", "hwlab-gateway-simu"].includes(doc.gatewaySession.serviceId), "gateway service must be gateway"); + assertDevEnvironment(doc.gatewaySession.environment, "gateway session environment"); + + const resourceIds = new Set(); + for (const resource of doc.resources) { + assertId(resource.resourceId, "resourceId"); + assert.equal(resource.projectId, doc.project.projectId, "resource projectId must match project"); + assert.equal(resource.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "resource gatewaySessionId must match gateway"); + assertDevEnvironment(resource.environment, `resource ${resource.resourceId} environment`); + resourceIds.add(resource.resourceId); + } + assert.ok(resourceIds.size >= 2, "topology example must include at least two resources"); + + const capabilityIds = new Set(); + for (const capability of doc.capabilities) { + assertId(capability.capabilityId, "capabilityId"); + assert.ok(resourceIds.has(capability.resourceId), `capability ${capability.capabilityId} must reference a known resource`); + assert.equal(capability.projectId, doc.project.projectId, "capability projectId must match project"); + assert.match(capability.name, actionPattern, "capability name must be dotted lower-case"); + assert.equal(typeof capability.mutatesState, "boolean", "capability mutatesState must be boolean"); + capabilityIds.add(capability.capabilityId); + } + assert.ok(capabilityIds.size >= 2, "topology example must include multiple capabilities"); + + const wiring = doc.wiringConfig; + assert.equal(wiring.projectId, doc.project.projectId, "wiring projectId must match project"); + assert.equal(wiring.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "wiring gatewaySessionId must match gateway"); + assert.equal(wiring.status, "active", "wiring example should show an active config"); + for (const connection of wiring.connections) { + assert.ok(resourceIds.has(connection.from.resourceId), "wiring from.resourceId must exist"); + assert.ok(resourceIds.has(connection.to.resourceId), "wiring to.resourceId must exist"); + if (connection.from.capabilityId) { + assert.ok(capabilityIds.has(connection.from.capabilityId), "wiring from.capabilityId must exist"); + } + if (connection.to.capabilityId) { + assert.ok(capabilityIds.has(connection.to.capabilityId), "wiring to.capabilityId must exist"); + } + } + + const patch = doc.patchPanelStatus; + assert.equal(patch.serviceId, "hwlab-patch-panel"); + assert.equal(patch.projectId, doc.project.projectId, "patch panel projectId must match project"); + assert.equal(patch.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "patch panel gatewaySessionId must match gateway"); + assert.equal(patch.wiringConfigId, wiring.wiringConfigId, "patch panel wiringConfigId must match wiring"); + assertDevEnvironment(patch.environment, "patch panel environment"); + for (const active of patch.activeConnections) { + assert.ok(resourceIds.has(active.fromResourceId), "active fromResourceId must exist"); + assert.ok(resourceIds.has(active.toResourceId), "active toResourceId must exist"); + } + + assert.equal(doc.agentSession.projectId, doc.project.projectId, "agent session projectId must match project"); + assert.equal(doc.agentSession.serviceId, "hwlab-agent-mgr"); + assertDevEnvironment(doc.agentSession.environment, "agent session environment"); + + assert.equal(doc.workerSession.projectId, doc.project.projectId, "worker session projectId must match project"); + assert.equal(doc.workerSession.agentSessionId, doc.agentSession.agentSessionId, "worker must reference agent session"); + assert.equal(doc.workerSession.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "worker must reference gateway session"); + assert.equal(doc.workerSession.serviceId, "hwlab-agent-worker"); + assertDevEnvironment(doc.workerSession.environment, "worker session environment"); + + const operation = doc.hardwareOperation; + assert.equal(operation.projectId, doc.project.projectId, "operation projectId must match project"); + assert.equal(operation.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "operation gatewaySessionId must match gateway"); + assert.equal(operation.agentSessionId, doc.agentSession.agentSessionId, "operation must reference agent session"); + assert.equal(operation.workerSessionId, doc.workerSession.workerSessionId, "operation must reference worker session"); + assert.ok(resourceIds.has(operation.resourceId), "operation resourceId must exist"); + assert.ok(capabilityIds.has(operation.capabilityId), "operation capabilityId must exist"); + assertDevEnvironment(operation.environment, "operation environment"); + + for (const traceEvent of doc.traceEvents) { + assert.equal(traceEvent.projectId, doc.project.projectId, "trace projectId must match project"); + assert.equal(traceEvent.operationId, operation.operationId, "trace operationId must match operation"); + assertServiceId(traceEvent.serviceId, "trace event"); + assertDevEnvironment(traceEvent.environment, "trace environment"); + } + + const evidence = doc.evidenceRecord; + assert.equal(evidence.projectId, doc.project.projectId, "evidence projectId must match project"); + assert.equal(evidence.operationId, operation.operationId, "evidence operationId must match operation"); + assert.equal(evidence.agentSessionId, doc.agentSession.agentSessionId, "evidence must reference agent session"); + assert.equal(evidence.workerSessionId, doc.workerSession.workerSessionId, "evidence must reference worker session"); + assert.match(evidence.sha256, /^[a-f0-9]{64}$/, "evidence sha256 must be lowercase hex"); + assertServiceId(evidence.serviceId, "evidence"); + assertDevEnvironment(evidence.environment, "evidence environment"); +} + +function assertDeployManifest(doc) { + assert.equal(doc.manifestVersion, "v1"); + assertDevEnvironment(doc.environment, "deploy manifest environment"); + assert.equal(doc.endpoint, DEV_ENDPOINT, "deploy manifest endpoint must be the frozen DEV endpoint"); + assert.ok(doc.profiles.dev.enabled, "deploy manifest dev profile must be enabled"); + assert.equal(doc.profiles.dev.endpoint, DEV_ENDPOINT, "deploy manifest dev profile endpoint must be frozen"); + assert.ok(!doc.profiles.prod, "M0 example must not include a PROD profile"); + + const serviceIds = doc.services.map((service) => service.serviceId); + assert.deepEqual(serviceIds, SERVICE_IDS, "deploy manifest example must cover every frozen service id in order"); + assertUnique("deploy service ids", serviceIds); + for (const service of doc.services) { + assertServiceId(service.serviceId, "deploy service"); + assert.equal(service.profile, "dev", `${service.serviceId} must use dev profile in M0 example`); + assert.equal(service.namespace, doc.namespace, `${service.serviceId} namespace must match manifest namespace`); + assert.ok(service.healthPath.startsWith("/"), `${service.serviceId} healthPath must be absolute`); + } +} + +async function assertAuditMarkdown() { + const doc = await readFile(path.join(repoRoot, auditDocPath), "utf8"); + for (const needle of [ + "# M0 Contract Audit", + "## Audit Result", + "## Coverage Matrix", + "## Rework Risk Register", + "## Validation" + ]) { + assert.ok(doc.includes(needle), `${auditDocPath} missing ${needle}`); + } + assert.ok(doc.includes("Blocker"), `${auditDocPath} must classify blocker risks`); + assert.ok(doc.includes("Enhancement"), `${auditDocPath} must classify enhancement risks`); +} + +const examples = await loadExamples(); +for (const required of [ + "service-ids.json", + "json-rpc-request.json", + "json-rpc-response.json", + "json-rpc-error.json", + "audit-event.json", + "capability-topology.json", + "deploy-manifest.dev.json" +]) { + assert.ok(examples.has(required), `${examplesDir} missing ${required}`); +} + +assertServiceIdsExample(examples.get("service-ids.json")); +assertJsonRpcExamples( + examples.get("json-rpc-request.json"), + examples.get("json-rpc-response.json"), + examples.get("json-rpc-error.json") +); +assertAuditEvent(examples.get("audit-event.json")); +assertCapabilityTopology(examples.get("capability-topology.json")); +assertDeployManifest(examples.get("deploy-manifest.dev.json")); +await assertAuditMarkdown(); + +console.log(`validated M0 contract examples: ${examples.size} JSON files, ${SERVICE_IDS.length} service ids`);