docs: add M0 contract audit
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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`);
|
||||
Reference in New Issue
Block a user