From 8ff93656ed1b301e55ef5f8031c20d0ed4f2b3fd Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 18:06:24 +0000 Subject: [PATCH] test: add m3 source contract dry-run plan --- docs/dev-evidence-blocker-aggregator.md | 2 +- docs/m3-hardware-loop.md | 42 ++- docs/reference/m3-loop-rollout-runbook.md | 20 ++ reports/dev-gate/dev-m3-hardware-loop.json | 253 +++++++++++++++ scripts/dev-m3-hardware-loop-smoke.mjs | 293 +++++++++++++++++- .../src/dev-evidence-blocker-aggregator.mjs | 5 +- scripts/validate-dev-gate-report.mjs | 69 ++++- scripts/validate-m3-rollout-runbook.mjs | 3 + 8 files changed, 663 insertions(+), 24 deletions(-) diff --git a/docs/dev-evidence-blocker-aggregator.md b/docs/dev-evidence-blocker-aggregator.md index 9d795214..3aeafc2c 100644 --- a/docs/dev-evidence-blocker-aggregator.md +++ b/docs/dev-evidence-blocker-aggregator.md @@ -77,7 +77,7 @@ green evidence. | M0 | `SOURCE` | `docs/m0-contract-audit.md`, protocol schemas, evidence chain examples, M0 validator. | | M1 | `LOCAL` | `docs/m1-local-smoke.md`, `fixtures/mvp/runtime.json`, `scripts/m1-contract-smoke.mjs`. | | M2 | `DEV-LIVE` for frontend/route only | DEV deploy smoke fixture plus active read-only `16666`/`16667` endpoint smoke and accepted frontend revision. | -| M3 | `BLOCKED` | Hardware trusted loop topology fixture and local patch-panel smoke exist, but DEV-LIVE operation/trace/audit/evidence IDs are missing. | +| M3 | `BLOCKED` | Hardware trusted loop topology fixture, local patch-panel smoke, and the no-write DEV plan exist, but DEV-LIVE operation/trace/audit/evidence IDs for `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` are missing. | | M4 | `BLOCKED` | Agent automation loop fixture and local smoke exist, but live preflight is blocked at DB live readiness. | | M5 | `BLOCKED` | MVP e2e dry-run plan is green, but bounded DEV-LIVE acceptance is blocked by DB/M3/M4 and source/artifact coverage. | diff --git a/docs/m3-hardware-loop.md b/docs/m3-hardware-loop.md index 51056fda..059d254d 100644 --- a/docs/m3-hardware-loop.md +++ b/docs/m3-hardware-loop.md @@ -72,16 +72,33 @@ remains `hwlab-patch-panel`, which calls the box internal port API. ## DEV Runtime Smoke -Use this command only for the DEV simulator runtime: +Use the no-write plan first: ```sh -node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production +node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run ``` -The smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first checks -the frozen DEV endpoint, `http://74.48.78.17:16667`, and stops at the first -critical blocker from `docs/dev-acceptance-matrix.md`. When DEV ingress is not -observable, the report is `blocked` and the script does not run the live +The dry-run plan writes `reports/dev-gate/dev-m3-hardware-loop.json` by +default, records `dryRunPlan.evidenceLevel: "DRY-RUN"`, lists the live write +preconditions, enumerates the endpoints that a later live run would call, and +keeps `liveOperation.status: "not_run"`. It does not call DEV endpoints, does +not send `/ports/write` or `/signals/route`, and cannot be promoted to +`DEV-LIVE`. + +Use this command only for the approved DEV simulator runtime window: + +```sh +node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod +``` + +The legacy `--confirmed-non-production` flag is accepted as compatibility, but +new handoffs should use `--expect-non-prod`. The script refuses the default +mode and refuses `--dry-run` combined with `--live`. + +The live smoke writes `reports/dev-gate/dev-m3-hardware-loop.json`. It first +checks the frozen DEV endpoint, `http://74.48.78.17:16667`, and stops at the +first critical blocker from `docs/dev-acceptance-matrix.md`. When DEV ingress +is not observable, the report is `blocked` and the script does not run the live `do.write true -> di.read true` operation. If DEV ingress is reachable, the script requires explicit direct DEV simulator @@ -95,11 +112,14 @@ HWLAB_DEV_GATEWAY_SIMU_2_URL HWLAB_DEV_PATCH_PANEL_URL ``` -Those targets must be HWLAB DEV simulator services, not UniDesk substitutes. -The script then checks two box simulators, two gateway simulators, active -patch-panel wiring for `box-simu-1 DO1 -> box-simu-2 DI1`, a live direct call -that reads `DI1=true`, and returned audit/evidence identifiers. Fixture-backed -local M3 output is never accepted as live DEV evidence. +Those targets must be two distinct HWLAB DEV simulator services for each +simulator kind, plus one HWLAB DEV patch panel, not UniDesk substitutes. The +script then checks two box simulators, two gateway simulators, active +patch-panel wiring for +`res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1`, a live direct +call that reads `DI1=true`, and returned operation, trace, audit, and evidence +identifiers. Fixture-backed local M3 output is never accepted as live DEV +evidence. ## Read-Only Continuation Evidence diff --git a/docs/reference/m3-loop-rollout-runbook.md b/docs/reference/m3-loop-rollout-runbook.md index 0fa7dc16..dc8c32a4 100644 --- a/docs/reference/m3-loop-rollout-runbook.md +++ b/docs/reference/m3-loop-rollout-runbook.md @@ -58,6 +58,26 @@ Cloud Web polish, generic console work, artifact reports, and desired-state planning are support surfaces only. They can help explain the system, but they do not count as M3 PASS evidence. +After the DB and Code Agent provider unblock, the next acceptable M3 step is +still not a blind live write. Run the no-write source contract first: + +```sh +node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run +``` + +The dry-run output is `DRY-RUN` evidence. It must list the live write +preconditions, the exact endpoints a later live run would call, and the +required evidence fields while keeping `liveOperation.status: "not_run"`. +Only an explicitly approved live window may use: + +```sh +node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod +``` + +The live command remains DEV-only and must stop before mutation unless the two +box-simu identities, two gateway-simu identities, and patch-panel-owned M3 +wiring are all observed. + ## Manual Acceptance Steps | Step | What to observe | Must have evidence | Failure class | Must not misread | diff --git a/reports/dev-gate/dev-m3-hardware-loop.json b/reports/dev-gate/dev-m3-hardware-loop.json index ce13d93a..4d8e5260 100644 --- a/reports/dev-gate/dev-m3-hardware-loop.json +++ b/reports/dev-gate/dev-m3-hardware-loop.json @@ -29,6 +29,8 @@ "node --check scripts/dev-m3-hardware-loop-smoke.mjs", "node --check scripts/validate-dev-m3-cardinality.mjs", "node scripts/validate-dev-m3-cardinality.mjs", + "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod", "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" @@ -40,13 +42,18 @@ "environment": "dev", "requiredBoxSimulators": 2, "requiredGatewaySimulators": 2, + "requiredPatchPanels": 1, + "requiredRoute": "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", "realHardwareAllowed": false, "prodAllowed": false }, "safetyGates": { "liveFlagRequired": true, "confirmDevRequired": true, + "expectNonProdRequired": true, "confirmedNonProductionRequired": true, + "dryRunPlanSupported": true, + "dryRunCallsLiveEndpoints": false, "prodForbidden": true, "realHardwareForbidden": true, "secretReadForbidden": true, @@ -71,6 +78,16 @@ "http://74.48.78.17:16667" ] }, + { + "id": "dry-run-live-write-plan", + "status": "not_run", + "summary": "Plan-only mode enumerated live write prerequisites and endpoints without calling DEV endpoints or mutating DEV state.", + "evidence": [ + "endpointCount=17", + "mutatingEndpointCount=2", + "dryRunCallsLiveEndpoints=false" + ] + }, { "id": "dev-ingress-health", "status": "pass", @@ -245,6 +262,13 @@ "classification": "direct_target_m3_wiring_missing", "sourceIssue": "pikasTech/HWLAB#64", "summary": "DEV patch-panel is callable, but live wiring does not contain res_boxsimu_1:DO1 -> res_boxsimu_2:DI1; active=res_boxsim_alpha:uart0->res_boxsim_beta:uart0, res_boxsim_alpha:gpio0->res_boxsim_beta:gpio0; configured=res_boxsim_alpha:uart0->res_boxsim_beta:uart0, res_boxsim_alpha:gpio0->res_boxsim_beta:gpio0." + }, + { + "type": "safety_blocker", + "scope": "m3-live-write-authorization", + "status": "open", + "classification": "dry_run_plan_only", + "summary": "Plan-only mode is intentionally non-mutating; run the bounded live smoke only after explicit DEV/non-PROD approval and after read-only preconditions identify the exact HWLAB targets." } ], "summary": { @@ -471,5 +495,234 @@ "syncableConnectionCount": 0 }, "summary": "Direct DEV M3 targets were probed read-only, but required identities or patch-panel M3 wiring were not satisfied." + }, + "dryRunPlan": { + "mode": "plan-only", + "evidenceLevel": "DRY-RUN", + "route": "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", + "dryRunCallsLiveEndpoints": false, + "liveWriteWillRun": false, + "liveWriteRequiresHumanApproval": true, + "liveCommand": "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod", + "legacyLiveCommand": "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", + "liveWritePreconditions": [ + "operator has authorized a bounded DEV M3 live smoke window", + "command includes --live, --confirm-dev, and --expect-non-prod", + "PROD, real hardware, secret reads, service restarts, and force pushes remain forbidden", + "DEV ingress identifies HWLAB DEV on the frozen 16666/16667 boundary", + "two distinct hwlab-box-simu targets report res_boxsimu_1 and res_boxsimu_2", + "two distinct hwlab-gateway-simu targets report distinct DEV gateway sessions", + "one hwlab-patch-panel reports active wiring for res_boxsimu_1:DO1 -> res_boxsimu_2:DI1", + "cross-device propagation is owned by hwlab-patch-panel; box loopback, SOURCE, LOCAL, fixture, and DRY-RUN output cannot be marked DEV-LIVE" + ], + "endpointPlan": [ + { + "phase": "dev-ingress-precondition", + "targetId": "dev-api-edge", + "serviceId": "hwlab-cloud-api", + "method": "GET", + "path": "/health/live", + "url": "http://74.48.78.17:16667/health/live", + "urlSource": "frozen-dev-endpoint", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "dev-ingress-precondition", + "targetId": "dev-api-edge-json-rpc", + "serviceId": "hwlab-cloud-api", + "method": "POST", + "path": "/json-rpc", + "url": "http://74.48.78.17:16667/json-rpc", + "urlSource": "frozen-dev-endpoint", + "mutatesDevState": false, + "calledInDryRun": false, + "requestShape": "system.health" + }, + { + "phase": "dev-ingress-precondition", + "targetId": "dev-frontend", + "serviceId": "hwlab-cloud-web", + "method": "GET", + "path": "/health/live", + "url": "http://74.48.78.17:16666/health/live", + "urlSource": "frozen-dev-frontend-endpoint", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-1", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_BOX_SIMU_1_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-1", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_BOX_SIMU_1_URL/status", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-2", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_BOX_SIMU_2_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "box-simu-2", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_BOX_SIMU_2_URL/status", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-1", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_GATEWAY_SIMU_1_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-1", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_GATEWAY_SIMU_1_URL/status", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_1_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-2", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_GATEWAY_SIMU_2_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "gateway-simu-2", + "serviceId": "hwlab-gateway-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_GATEWAY_SIMU_2_URL/status", + "urlSource": "required-env:HWLAB_DEV_GATEWAY_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "GET", + "path": "/health/live", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/health/live", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "direct-target-precondition", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/status", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "patch-panel-route-precondition", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "GET", + "path": "/wiring", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/wiring", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": false, + "calledInDryRun": false + }, + { + "phase": "live-write-source-do1", + "targetId": "box-simu-1", + "serviceId": "hwlab-box-simu", + "method": "POST", + "path": "/ports/write", + "url": "$HWLAB_DEV_BOX_SIMU_1_URL/ports/write", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_1_URL", + "mutatesDevState": true, + "calledInDryRun": false + }, + { + "phase": "live-write-patch-panel-route", + "targetId": "patch-panel", + "serviceId": "hwlab-patch-panel", + "method": "POST", + "path": "/signals/route", + "url": "$HWLAB_DEV_PATCH_PANEL_URL/signals/route", + "urlSource": "required-env:HWLAB_DEV_PATCH_PANEL_URL", + "mutatesDevState": true, + "calledInDryRun": false + }, + { + "phase": "live-read-target-di1", + "targetId": "box-simu-2", + "serviceId": "hwlab-box-simu", + "method": "GET", + "path": "/status", + "url": "$HWLAB_DEV_BOX_SIMU_2_URL/status", + "urlSource": "required-env:HWLAB_DEV_BOX_SIMU_2_URL", + "mutatesDevState": false, + "calledInDryRun": false + } + ], + "evidenceFields": [ + "operationId", + "traceId", + "auditId", + "evidenceId", + "sourceResourceId=res_boxsimu_1", + "sourcePort=DO1", + "routeOwner=hwlab-patch-panel", + "routeResponse.propagatedBy=hwlab-patch-panel", + "targetResourceId=res_boxsimu_2", + "targetPort=DI1", + "targetState.ports.DI1.propagatedBy=hwlab-patch-panel" + ], + "refusalPolicy": [ + "no arguments defaults to refusal", + "--dry-run/--plan cannot be combined with --live/--allow-live", + "live mode refuses without --confirm-dev and --expect-non-prod", + "a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass" + ] } } diff --git a/scripts/dev-m3-hardware-loop-smoke.mjs b/scripts/dev-m3-hardware-loop-smoke.mjs index 21a8a4b7..f729fbe5 100644 --- a/scripts/dev-m3-hardware-loop-smoke.mjs +++ b/scripts/dev-m3-hardware-loop-smoke.mjs @@ -19,6 +19,9 @@ const issue = "pikasTech/HWLAB#38"; const deployWorkloadsPath = "deploy/k8s/base/workloads.yaml"; const deployServicesPath = "deploy/k8s/base/services.yaml"; const requiredManifestCardinalityCommand = "node scripts/validate-dev-m3-cardinality.mjs"; +const dryRunPlanCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run"; +const liveSmokeCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod"; +const legacyLiveSmokeCommand = "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"; const requiredM3BoxIds = Object.freeze(["boxsimu_1", "boxsimu_2"]); const requiredM3BoxResources = Object.freeze(["res_boxsimu_1", "res_boxsimu_2"]); const requiredM3Connection = Object.freeze({ @@ -84,10 +87,15 @@ function parseArgs(argv) { flags.add(arg); } - return { flags, values }; + return { + flags, + values, + dryRun: flags.has("--dry-run") || flags.has("--plan"), + live: flags.has("--live") || flags.has("--allow-live") + }; } -function requireSafetyGates(flags) { +function requireSafetyGates({ flags, dryRun, live }) { const forbiddenFlags = [ "--prod", "--real-hardware", @@ -104,13 +112,32 @@ function requireSafetyGates(flags) { assert.ok(!flags.has(flag), `${flag} is forbidden for DEV M3 smoke`); } - const live = flags.has("--live") || flags.has("--allow-live"); - assert.ok(live, "DEV M3 smoke requires --live or --allow-live"); + assert.ok( + !(dryRun && live), + "DEV M3 smoke refuses to combine --dry-run/--plan with --live/--allow-live; plan mode never runs the M3 write path" + ); + + if (dryRun) { + return { + mode: "dry-run", + liveWriteAllowed: false + }; + } + + assert.ok( + live, + "DEV M3 smoke refuses to run by default; use --dry-run for a no-write plan or --live --confirm-dev --expect-non-prod after approval" + ); assert.ok(flags.has("--confirm-dev"), "live DEV smoke requires --confirm-dev"); assert.ok( - flags.has("--confirmed-non-production"), - "live DEV smoke requires --confirmed-non-production" + flags.has("--expect-non-prod") || flags.has("--confirmed-non-production"), + "live DEV smoke requires --expect-non-prod (legacy --confirmed-non-production is accepted only as compatibility)" ); + + return { + mode: "live", + liveWriteAllowed: true + }; } function isoNow() { @@ -484,6 +511,152 @@ function joinUrl(baseUrl, routePath) { return new URL(routePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString(); } +function endpointReference(target, routePath) { + const baseUrl = process.env[target.urlEnv]; + if (baseUrl) { + return { + url: joinUrl(baseUrl, routePath), + urlSource: `env:${target.urlEnv}` + }; + } + + return { + url: `$${target.urlEnv}${routePath}`, + urlSource: `required-env:${target.urlEnv}` + }; +} + +function plannedServiceEndpoint(target, routePath, { method = "GET", phase, mutatesDevState = false }) { + const endpoint = endpointReference(target, routePath); + return { + phase, + targetId: target.id, + serviceId: target.serviceId, + method, + path: routePath, + ...endpoint, + mutatesDevState, + calledInDryRun: false + }; +} + +function createLiveOperationPlan() { + const box1 = serviceTargets.find((target) => target.id === "box-simu-1"); + const box2 = serviceTargets.find((target) => target.id === "box-simu-2"); + const patchPanel = serviceTargets.find((target) => target.id === "patch-panel"); + const endpointPlan = [ + { + phase: "dev-ingress-precondition", + targetId: "dev-api-edge", + serviceId: "hwlab-cloud-api", + method: "GET", + path: "/health/live", + url: joinUrl(DEV_ENDPOINT, "/health/live"), + urlSource: "frozen-dev-endpoint", + mutatesDevState: false, + calledInDryRun: false + }, + { + phase: "dev-ingress-precondition", + targetId: "dev-api-edge-json-rpc", + serviceId: "hwlab-cloud-api", + method: "POST", + path: "/json-rpc", + url: joinUrl(DEV_ENDPOINT, "/json-rpc"), + urlSource: "frozen-dev-endpoint", + mutatesDevState: false, + calledInDryRun: false, + requestShape: "system.health" + }, + { + phase: "dev-ingress-precondition", + targetId: "dev-frontend", + serviceId: "hwlab-cloud-web", + method: "GET", + path: "/health/live", + url: joinUrl(DEV_FRONTEND_ENDPOINT, "/health/live"), + urlSource: "frozen-dev-frontend-endpoint", + mutatesDevState: false, + calledInDryRun: false + } + ]; + + for (const target of serviceTargets) { + endpointPlan.push( + plannedServiceEndpoint(target, "/health/live", { + phase: "direct-target-precondition" + }), + plannedServiceEndpoint(target, target.statusPath, { + phase: "direct-target-precondition" + }) + ); + if (target.wiringPath) { + endpointPlan.push( + plannedServiceEndpoint(target, target.wiringPath, { + phase: "patch-panel-route-precondition" + }) + ); + } + } + + endpointPlan.push( + plannedServiceEndpoint(box1, "/ports/write", { + method: "POST", + phase: "live-write-source-do1", + mutatesDevState: true + }), + plannedServiceEndpoint(patchPanel, patchPanel.routePath, { + method: "POST", + phase: "live-write-patch-panel-route", + mutatesDevState: true + }), + plannedServiceEndpoint(box2, "/status", { + phase: "live-read-target-di1" + }) + ); + + return { + mode: "plan-only", + evidenceLevel: "DRY-RUN", + route: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", + dryRunCallsLiveEndpoints: false, + liveWriteWillRun: false, + liveWriteRequiresHumanApproval: true, + liveCommand: liveSmokeCommand, + legacyLiveCommand: legacyLiveSmokeCommand, + liveWritePreconditions: [ + "operator has authorized a bounded DEV M3 live smoke window", + "command includes --live, --confirm-dev, and --expect-non-prod", + "PROD, real hardware, secret reads, service restarts, and force pushes remain forbidden", + "DEV ingress identifies HWLAB DEV on the frozen 16666/16667 boundary", + "two distinct hwlab-box-simu targets report res_boxsimu_1 and res_boxsimu_2", + "two distinct hwlab-gateway-simu targets report distinct DEV gateway sessions", + "one hwlab-patch-panel reports active wiring for res_boxsimu_1:DO1 -> res_boxsimu_2:DI1", + "cross-device propagation is owned by hwlab-patch-panel; box loopback, SOURCE, LOCAL, fixture, and DRY-RUN output cannot be marked DEV-LIVE" + ], + endpointPlan, + evidenceFields: [ + "operationId", + "traceId", + "auditId", + "evidenceId", + "sourceResourceId=res_boxsimu_1", + "sourcePort=DO1", + "routeOwner=hwlab-patch-panel", + "routeResponse.propagatedBy=hwlab-patch-panel", + "targetResourceId=res_boxsimu_2", + "targetPort=DI1", + "targetState.ports.DI1.propagatedBy=hwlab-patch-panel" + ], + refusalPolicy: [ + "no arguments defaults to refusal", + "--dry-run/--plan cannot be combined with --live/--allow-live", + "live mode refuses without --confirm-dev and --expect-non-prod", + "a dry-run, source, local, fixture, or read-only blocker report leaves liveOperation.status as not_run or blocked, never pass" + ] + }; +} + function isHwlabDevIdentity(probe) { const body = probe.json; if (!probe.ok || !body || typeof body !== "object" || Array.isArray(body)) { @@ -1075,6 +1248,7 @@ async function runLiveM3Targets(targets) { probes.push({ id: "patch-panel", kind: "signals.route", probe: route }); assert.ok(route.ok, "patch-panel signal route failed"); assert.equal(route.json?.accepted, true, "patch-panel signal route accepted"); + assert.equal(route.json?.propagatedBy, "hwlab-patch-panel", "patch-panel signal route owner"); assert.equal(route.json?.deliveryCount, 1, "patch-panel signal route delivery count"); const after = await probeJson(joinUrl(box2Target.baseUrl, "/status")); @@ -1122,7 +1296,9 @@ function baseReport({ commitId, observedAt }) { "node --check scripts/dev-m3-hardware-loop-smoke.mjs", "node --check scripts/validate-dev-m3-cardinality.mjs", requiredManifestCardinalityCommand, - "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production", + dryRunPlanCommand, + liveSmokeCommand, + legacyLiveSmokeCommand, "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ], @@ -1133,13 +1309,18 @@ function baseReport({ commitId, observedAt }) { environment: ENVIRONMENT_DEV, requiredBoxSimulators: 2, requiredGatewaySimulators: 2, + requiredPatchPanels: 1, + requiredRoute: "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", realHardwareAllowed: false, prodAllowed: false }, safetyGates: { liveFlagRequired: true, confirmDevRequired: true, + expectNonProdRequired: true, confirmedNonProductionRequired: true, + dryRunPlanSupported: true, + dryRunCallsLiveEndpoints: false, prodForbidden: true, realHardwareForbidden: true, secretReadForbidden: true, @@ -1193,6 +1374,94 @@ function baseReport({ commitId, observedAt }) { }; } +async function runDryRunPlan({ values }) { + await ensureContractFiles(); + + const reportPath = resolveReportPath(values.get("output")); + const observedAt = isoNow(); + const report = baseReport({ + commitId: currentCommit(), + observedAt + }); + const plan = createLiveOperationPlan(); + + report.dryRunPlan = plan; + report.readOnlySupplementalEvidence = await collectReadOnlySupplementalEvidence(); + report.liveChecks.push( + { + id: "dry-run-live-write-plan", + status: "not_run", + summary: "Plan-only mode enumerated live write prerequisites and endpoints without calling DEV endpoints or mutating DEV state.", + evidence: [ + `endpointCount=${plan.endpointPlan.length}`, + `mutatingEndpointCount=${plan.endpointPlan.filter((endpoint) => endpoint.mutatesDevState).length}`, + "dryRunCallsLiveEndpoints=false" + ] + }, + { + id: "two-box-simu-online", + status: "not_run", + summary: "Plan-only mode requires two distinct live DEV box-simu identities before any write; no DEV target was probed.", + evidence: ["required=res_boxsimu_1,res_boxsimu_2"] + }, + { + id: "two-gateway-simu-online", + status: "not_run", + summary: "Plan-only mode requires two distinct live DEV gateway-simu identities before any write; no DEV target was probed.", + evidence: ["required=gwsimu_1,gwsimu_2"] + }, + { + id: "patch-panel-healthy", + status: "not_run", + summary: "Plan-only mode requires a callable DEV hwlab-patch-panel before any write; no DEV target was probed.", + evidence: ["required=hwlab-patch-panel"] + }, + { + id: "wiring-do1-di1-applied", + status: "not_run", + summary: "Plan-only mode requires patch-panel-owned res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 wiring before any write.", + evidence: [`route=${plan.route}`] + }, + { + id: "direct-call-do-write-di-read", + status: "not_run", + summary: "Plan-only mode did not send /ports/write or /signals/route; this output is DRY-RUN only and cannot be labeled DEV-LIVE.", + evidence: ["No live DEV write was sent."] + }, + { + id: "audit-evidence-traceable", + status: "not_run", + summary: "Plan-only mode listed the required operation, trace, audit, and evidence fields but did not create them.", + evidence: plan.evidenceFields + } + ); + report.liveOperation = { + status: "not_run", + operationId: "not_observed", + traceId: "not_observed", + auditId: "not_observed", + evidenceId: "not_observed", + summary: "Dry-run plan only; no DEV-LIVE operation was attempted or claimed." + }; + report.blockers.push({ + type: "safety_blocker", + scope: "m3-live-write-authorization", + status: "open", + classification: "dry_run_plan_only", + summary: "Plan-only mode is intentionally non-mutating; run the bounded live smoke only after explicit DEV/non-PROD approval and after read-only preconditions identify the exact HWLAB targets." + }); + report.summary = { + status: "blocked", + classification: "dry_run_plan_only", + observedAt, + result: "DRY-RUN plan emitted live write prerequisites, endpoint plan, and required evidence fields; it did not call DEV endpoints or produce DEV-LIVE evidence." + }; + + await writeReport(report, reportPath); + console.log(`[dev-m3-smoke] mode=dry-run status=blocked report=${relativePath(reportPath)}`); + console.log("[dev-m3-smoke] dry-run: no DEV endpoint calls and no live write were sent"); +} + function addNotRunM3Checks(report, reason) { for (const id of [ "two-box-simu-online", @@ -1223,8 +1492,14 @@ async function writeReport(report, reportPath) { } async function main() { - const { flags, values } = parseArgs(process.argv.slice(2)); - requireSafetyGates(flags); + const args = parseArgs(process.argv.slice(2)); + const safety = requireSafetyGates(args); + if (safety.mode === "dry-run") { + await runDryRunPlan(args); + return; + } + + const { values } = args; await ensureContractFiles(); const reportPath = resolveReportPath(values.get("output")); diff --git a/scripts/src/dev-evidence-blocker-aggregator.mjs b/scripts/src/dev-evidence-blocker-aggregator.mjs index ebfae73e..bc99ecc0 100644 --- a/scripts/src/dev-evidence-blocker-aggregator.mjs +++ b/scripts/src/dev-evidence-blocker-aggregator.mjs @@ -522,7 +522,10 @@ function collectM3Evidence(reports) { `auditId=${m3.liveOperation?.auditId ?? "not_observed"}`, `evidenceId=${m3.liveOperation?.evidenceId ?? "not_observed"}` ], - commands: ["node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"], + commands: [ + "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod" + ], summary: liveSummary }) ]; diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 1b86e14a..1b04b3c3 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -85,8 +85,11 @@ const requiredDevM3ValidationCommands = [ "node --check scripts/dev-m3-hardware-loop-smoke.mjs", "node --check scripts/validate-dev-m3-cardinality.mjs", "node scripts/validate-dev-m3-cardinality.mjs", - "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production" + "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod" ]; +const legacyDevM3LiveCommand = + "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"; const requiredDevM3Docs = [ "docs/dev-acceptance-matrix.md", "docs/m3-hardware-loop.md" @@ -1334,6 +1337,10 @@ async function validateDevM3Report(report, label) { `${label}.validationCommands missing ${requiredCommand}` ); } + assert.ok( + report.validationCommands.includes(legacyDevM3LiveCommand), + `${label}.validationCommands missing legacy compatibility command ${legacyDevM3LiveCommand}` + ); assertObject(report.runtimeTarget, `${label}.runtimeTarget`); assert.equal(report.runtimeTarget.endpoint, "http://74.48.78.17:16667", `${label}.runtimeTarget.endpoint`); @@ -1348,6 +1355,16 @@ async function validateDevM3Report(report, label) { assert.equal(report.runtimeTarget.environment, "dev", `${label}.runtimeTarget.environment`); assert.equal(report.runtimeTarget.requiredBoxSimulators, 2, `${label}.runtimeTarget.requiredBoxSimulators`); assert.equal(report.runtimeTarget.requiredGatewaySimulators, 2, `${label}.runtimeTarget.requiredGatewaySimulators`); + if (Object.hasOwn(report.runtimeTarget, "requiredPatchPanels")) { + assert.equal(report.runtimeTarget.requiredPatchPanels, 1, `${label}.runtimeTarget.requiredPatchPanels`); + } + if (Object.hasOwn(report.runtimeTarget, "requiredRoute")) { + assert.equal( + report.runtimeTarget.requiredRoute, + "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", + `${label}.runtimeTarget.requiredRoute` + ); + } assert.equal(report.runtimeTarget.realHardwareAllowed, false, `${label}.runtimeTarget.realHardwareAllowed`); assert.equal(report.runtimeTarget.prodAllowed, false, `${label}.runtimeTarget.prodAllowed`); @@ -1355,7 +1372,9 @@ async function validateDevM3Report(report, label) { for (const field of [ "liveFlagRequired", "confirmDevRequired", + "expectNonProdRequired", "confirmedNonProductionRequired", + "dryRunPlanSupported", "prodForbidden", "realHardwareForbidden", "secretReadForbidden", @@ -1364,6 +1383,9 @@ async function validateDevM3Report(report, label) { ]) { assert.equal(report.safetyGates[field], true, `${label}.safetyGates.${field}`); } + if (Object.hasOwn(report.safetyGates, "dryRunCallsLiveEndpoints")) { + assert.equal(report.safetyGates.dryRunCallsLiveEndpoints, false, `${label}.safetyGates.dryRunCallsLiveEndpoints`); + } assertArray(report.liveChecks, `${label}.liveChecks`); assert.ok(report.liveChecks.length >= 8, `${label}.liveChecks must include DEV M3 checks`); @@ -1449,7 +1471,14 @@ async function validateDevM3Report(report, label) { ["pass", "gap", "source-ready", "manifest-ready"].includes(evidence.status), `${evidenceLabel}.status must be pass, gap, source-ready, or manifest-ready` ); - assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`); + if (Array.isArray(evidence.source)) { + assertStringArray(evidence.source, `${evidenceLabel}.source`, { minLength: 1 }); + for (const [sourceIndex, sourcePath] of evidence.source.entries()) { + assertRepoRelativePath(sourcePath, `${evidenceLabel}.source[${sourceIndex}]`); + } + } else { + assertRepoRelativePath(evidence.source, `${evidenceLabel}.source`); + } assertString(evidence.summary, `${evidenceLabel}.summary`); assertObject(evidence.evidence, `${evidenceLabel}.evidence`); assertString(evidence.requiredFollowUp, `${evidenceLabel}.requiredFollowUp`); @@ -1462,6 +1491,42 @@ async function validateDevM3Report(report, label) { } } + if (Object.hasOwn(report, "dryRunPlan")) { + const plan = report.dryRunPlan; + assertObject(plan, `${label}.dryRunPlan`); + assert.equal(plan.mode, "plan-only", `${label}.dryRunPlan.mode`); + assert.equal(plan.evidenceLevel, "DRY-RUN", `${label}.dryRunPlan.evidenceLevel`); + assert.equal(plan.route, "res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1", `${label}.dryRunPlan.route`); + assert.equal(plan.dryRunCallsLiveEndpoints, false, `${label}.dryRunPlan.dryRunCallsLiveEndpoints`); + assert.equal(plan.liveWriteWillRun, false, `${label}.dryRunPlan.liveWriteWillRun`); + assertStringArray(plan.liveWritePreconditions, `${label}.dryRunPlan.liveWritePreconditions`, { minLength: 4 }); + assertStringArray(plan.evidenceFields, `${label}.dryRunPlan.evidenceFields`, { minLength: 4 }); + assertArray(plan.endpointPlan, `${label}.dryRunPlan.endpointPlan`); + assert.ok(plan.endpointPlan.length >= 8, `${label}.dryRunPlan.endpointPlan must enumerate precondition and live endpoints`); + assert.ok( + plan.endpointPlan.some((endpoint) => endpoint.serviceId === "hwlab-patch-panel" && endpoint.path === "/signals/route" && endpoint.mutatesDevState === true), + `${label}.dryRunPlan.endpointPlan must include the patch-panel route write endpoint` + ); + assert.ok( + plan.endpointPlan.some((endpoint) => endpoint.serviceId === "hwlab-box-simu" && endpoint.path === "/ports/write" && endpoint.mutatesDevState === true), + `${label}.dryRunPlan.endpointPlan must include the source box DO1 write endpoint` + ); + assert.ok( + plan.endpointPlan.every((endpoint) => endpoint.calledInDryRun === false), + `${label}.dryRunPlan.endpointPlan endpoints must not be called in dry-run` + ); + for (const requiredField of [ + "operationId", + "traceId", + "auditId", + "evidenceId", + "routeResponse.propagatedBy=hwlab-patch-panel", + "targetState.ports.DI1.propagatedBy=hwlab-patch-panel" + ]) { + assert.ok(plan.evidenceFields.includes(requiredField), `${label}.dryRunPlan.evidenceFields missing ${requiredField}`); + } + } + assertObject(report.liveOperation, `${label}.liveOperation`); assertStatus(report.liveOperation.status, `${label}.liveOperation.status`); for (const field of ["operationId", "traceId", "auditId", "evidenceId", "summary"]) { diff --git a/scripts/validate-m3-rollout-runbook.mjs b/scripts/validate-m3-rollout-runbook.mjs index 718ee0eb..acfe3bc0 100644 --- a/scripts/validate-m3-rollout-runbook.mjs +++ b/scripts/validate-m3-rollout-runbook.mjs @@ -41,6 +41,9 @@ async function main() { assertIncludes(doc, "deploy/artifact-catalog.dev.json", "m3 runbook"); assertIncludes(doc, "pikasTech/HWLAB#63", "m3 runbook"); assertIncludes(doc, "Do not promote SOURCE / LOCAL / DRY-RUN / fixture output to `DEV-LIVE`.", "m3 runbook"); + assertIncludes(doc, "node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run", "m3 runbook"); + assertIncludes(doc, "node scripts/dev-m3-hardware-loop-smoke.mjs --live --confirm-dev --expect-non-prod", "m3 runbook"); + assertIncludes(doc, "liveOperation.status: \"not_run\"", "m3 runbook"); assertNotIncludes(boundary, "http://74.48.78.17:6666/", "m3 current boundary"); assertNotIncludes(boundary, "http://74.48.78.17:6667/", "m3 current boundary"); assertIncludes(boundary, "http://74.48.78.17:16666/", "m3 current boundary");