fix(slice-09): preflight explicit --scope before daemon contact (guard BLOCKING re-verify-2)

Guard verdict qitem-20260518045300 — narrow CLI evidence/UX bug. The
prior forward-fix moved normalizeScopeQualifier inside withClient, so
explicit `--scope global_host --qualifier <X>` would still cause a
daemon-status check + GET /defaults BEFORE rejection. Two costs:

1. Evidence mismatch — the prior commit claimed "no daemon GET
   /defaults on rejection" but the tests only asserted no-PUT.
2. UX: an operator running an explicitly invalid invocation while the
   daemon is down would see "Daemon not running" instead of the local
   input error. The convention's scope rule applies at operator
   invocation, not at daemon HTTP parsing.

Fix:
- When `--scope` is explicitly provided, preflight scope validity +
  normalizeScopeQualifier BEFORE getDeps()/withClient. Reject locally
  with the proper input error; zero daemon calls.
- When `--scope` is implicit, keep the existing defaults-fetch path
  (the operator needs the recommended per-mode default scope to
  build the binding); normalize the inferred scope afterward.
- unset already preflights correctly (unchanged).

Discriminators updated:
- existing explicit-invalid tests now assert `calls.length === 0`
  (not just no-PUT) — the qitem's claimed evidence is real.
- new test: explicit non-global scope missing qualifier → zero calls.
- new test: explicit unknown scope rejected locally → zero calls.
- new test: implicit-default-scope path STILL fetches defaults (the
  fix didn't break that path) — `set debug --qualifier q-1 --confirm`
  records one GET /defaults + one PUT.
- new test: implicit-default-scope with missing-qualifier still
  rejected (after defaults fetch), no PUT.

Test totals: 113 (was 109). 80 daemon (unchanged) + 33 CLI (was 29).
CLI tsc EXIT=0.
This commit is contained in:
mvschwarz committed 2026-05-17 21:54:54 -07:00
1 parent 1cccc22678
commit 27e7fc1ab1
2 files changed
+85 -14

No files matched your search

+36 -5
View File
@@ -202,6 +202,34 @@ export function rigPolicyCommand(depsOverride?: RigPolicyDeps): Command {
process.exitCode = 1;
return;
}
// BLOCKING re-verify-2 (qitem-20260518045300): when --scope is
// explicit, validate scope + qualifier LOCALLY before touching
// the daemon. Otherwise an invalid operator invocation
// (e.g. `--scope global_host --qualifier X`) surfaces as
// "Daemon not running" when the daemon is down, instead of the
// proper local input error. Convention's scope rule applies at
// operator invocation, not at daemon HTTP parsing.
let explicitScope: Scope | null = null;
if (opts.scope !== undefined) {
if (!(SCOPES as readonly string[]).includes(opts.scope)) {
console.error(`Unknown scope '${opts.scope}'. Allowed: ${SCOPES.join(", ")}.`);
process.exitCode = 1;
return;
}
explicitScope = opts.scope as Scope;
const preflight = normalizeScopeQualifier(explicitScope, opts.qualifier);
if (!preflight.ok) {
if (opts.json) {
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: preflight.message }, null, 2));
} else {
console.error(preflight.message);
}
process.exitCode = 1;
return;
}
}
const deps = getDeps();
await withClient(deps, async (client) => {
const defaultsRes = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
@@ -211,16 +239,19 @@ export function rigPolicyCommand(depsOverride?: RigPolicyDeps): Command {
return;
}
const defaults = defaultsRes.data;
const scope = (opts.scope as Scope | undefined) ?? defaults.recommendedDefaultScope[mode];
const scope = explicitScope ?? defaults.recommendedDefaultScope[mode];
// Defensive — the defaults map is from the daemon, so its scope
// values are trusted; keep the assertion shape lean.
if (!(SCOPES as readonly string[]).includes(scope)) {
console.error(`Unknown scope '${scope}'. Allowed: ${SCOPES.join(", ")}.`);
process.exitCode = 1;
return;
}
// BLOCKING re-verify (qitem-20260518044650): never silently drop
// an explicit operator-supplied qualifier on global_host. Reject
// BEFORE the proposed-binding restate so the operator sees the
// input error rather than a misleading proposed binding.
// Re-run normalization for the implicit-default-scope case.
// (Explicit scope was already preflighted above; this branch is
// a no-op for explicit-scope inputs but is needed when the
// default scope is e.g. `qitem`/`workstream` and the operator
// forgot --qualifier.)
const normalized = normalizeScopeQualifier(scope, opts.qualifier);
if (!normalized.ok) {
if (opts.json) {
+49 -9
View File
@@ -293,39 +293,57 @@ describe("normalizeScopeQualifier (CLI mirror of daemon-route parseScopeAndQuali
});
});
describe("BLOCKING re-verify (qitem-20260518044650): set --scope global_host --qualifier <X> rejected at CLI before any daemon call", () => {
it("WITHOUT --confirm: exits 1; daemon NOT contacted (no GET defaults, no restate, no PUT)", async () => {
describe("BLOCKING re-verify-2 (qitem-20260518045300): explicit --scope global_host --qualifier <X> rejected BEFORE any daemon contact", () => {
it("WITHOUT --confirm: exits 1; daemon NOT contacted at all (no GET /defaults, no PUT, no restate)", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected"]);
expect(process.exitCode).toBe(1);
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
// BLOCKING re-verify-2 evidence: zero daemon calls of any kind.
expect(calls).toHaveLength(0);
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
// CRITICAL: no misleading "Proposed binding" restate happens.
expect(logs.join("\n")).not.toContain("Proposed binding");
});
it("WITH --confirm: exits 1; daemon NOT contacted (no PUT)", async () => {
it("WITH --confirm: exits 1; daemon NOT contacted (no GET /defaults, no PUT)", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected", "--confirm"]);
expect(process.exitCode).toBe(1);
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
expect(calls).toHaveLength(0);
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
});
it("WITH --json: emits ok:false + qualifier_invalid; no PUT", async () => {
it("WITH --json: emits ok:false + qualifier_invalid; zero daemon calls", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--qualifier", "unexpected", "--json"]);
expect(process.exitCode).toBe(1);
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
expect(calls).toHaveLength(0);
const parsed = JSON.parse(logs.join("\n"));
expect(parsed.ok).toBe(false);
expect(parsed.error).toBe("qualifier_invalid");
});
it("Positive: set sleep without --qualifier targets /bindings/global_host (no trailing segment)", async () => {
it("Explicit non-global scope missing qualifier ALSO preflighted: zero daemon calls", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
await cmd.parseAsync(["node", "rig", "set", "debug", "--scope", "rig"]);
expect(process.exitCode).toBe(1);
expect(calls).toHaveLength(0);
expect(errs.join("\n")).toMatch(/requires a qualifier/);
});
it("Explicit unknown scope rejected locally: zero daemon calls", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
await cmd.parseAsync(["node", "rig", "set", "debug", "--scope", "banana", "--qualifier", "q-1"]);
expect(process.exitCode).toBe(1);
expect(calls).toHaveLength(0);
expect(errs.join("\n")).toMatch(/Unknown scope 'banana'/);
});
it("Positive: set sleep --scope global_host --confirm → PUT /bindings/global_host (one GET /defaults + one PUT)", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
await cmd.parseAsync(["node", "rig", "set", "sleep", "--scope", "global_host", "--confirm"]);
@@ -333,6 +351,28 @@ describe("BLOCKING re-verify (qitem-20260518044650): set --scope global_host --q
const put = calls.find((c) => c.method === "PUT")!;
expect(put.path).toBe("/api/rig-policy/bindings/global_host");
});
it("Implicit-default-scope path STILL fetches defaults (no preflight rejection): set debug without --scope reaches GET /defaults + PUT", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
expect(process.exitCode).toBeUndefined();
const gets = calls.filter((c) => c.method === "GET" && c.path.startsWith("/api/rig-policy/defaults"));
const puts = calls.filter((c) => c.method === "PUT");
expect(gets.length).toBe(1);
expect(puts.length).toBe(1);
expect(puts[0]!.path).toBe("/api/rig-policy/bindings/qitem/q-1");
});
it("Implicit-default-scope path still rejects missing qualifier when default scope needs one (no PUT)", async () => {
const { client, calls } = fakeClient({});
const cmd = rigPolicyCommand(deps(client));
// `debug` default scope is `qitem`; without --qualifier the post-defaults normalization rejects.
await cmd.parseAsync(["node", "rig", "set", "debug"]);
expect(process.exitCode).toBe(1);
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
expect(errs.join("\n")).toMatch(/requires a qualifier/);
});
});
describe("rig policy unset — operator-only DELETE", () => {