test: add M3 source contract dry-run plan

Host commander merge: clean PR, no live write, preserves DRY-RUN vs DEV-LIVE boundary for #78/#7/#164.
This commit is contained in:
Lyon
2026-05-23 02:20:11 +08:00
committed by GitHub
8 changed files with 663 additions and 24 deletions
+1 -1
View File
@@ -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. |
+31 -11
View File
@@ -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
+20
View File
@@ -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 |
+253
View File
@@ -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"
]
}
}
+284 -9
View File
@@ -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"));
@@ -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
})
];
+67 -2
View File
@@ -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"]) {
+3
View File
@@ -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");