mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
merge: slice 09 — rig-policy primitive (release-0.3.2)
Graduates the operator-context-mode doctrine into a source-side typed primitive: 6 modes, frozen 10-field schema, 4 scopes, scope-hierarchy resolution, daemon store + operator-only HTTP routes, rig policy CLI (set/show/effective/cite/unset), drift-rule discipline. Substrate skill + convention pointer graduated to the shipped primitive. driver (redo-driver-2, restored seat) -> guard CLEAR @27e7fc1aafter a rigorous 3-round BLOCKING loop (mode binding-level not in 10-field record; global_host+qualifier rejected at route AND CLI; explicit-scope preflight-before-daemon) -> velocity-qa HG-10 adversarial dogfood CLEAR @27e7fc1a(clean, product source untouched, no post-guard delta). Orch heavy pre-merge verify: tip==guard==qa-cleared27e7fc1a, 0 commits-after, base7a12c930unmoved, no conflicts, evidence present. HG-SAFE gate-zero held: zero permission/runtime-config mutation across all 10 commits; permission_prompt_posture uses the real safe value do_not_prompt_unless_blocked. Local main only -- no push. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
commit
55e01f2e47
19 files changed
+3372
-3
No files matched your search
@@ -16,6 +16,8 @@ export interface DaemonResponse<T = unknown> {
|
||||
|
||||
interface DaemonRequestOptions {
|
||||
timeoutMs?: number;
|
||||
/** Per-call header overrides (e.g., `Authorization: Bearer ...`). */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DaemonClientOptions {
|
||||
@@ -79,7 +81,20 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async delete<T = unknown>(path: string, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
return this.requestJson<T>(path, { method: "DELETE" }, options);
|
||||
return this.requestJson<T>(path, {
|
||||
method: "DELETE",
|
||||
headers: options?.headers,
|
||||
}, options);
|
||||
}
|
||||
|
||||
async put<T = unknown>(path: string, body?: unknown, options?: DaemonRequestOptions): Promise<DaemonResponse<T>> {
|
||||
const baseHeaders: Record<string, string> = body !== undefined ? { "Content-Type": "application/json" } : {};
|
||||
const headers = { ...baseHeaders, ...(options?.headers ?? {}) };
|
||||
return this.requestJson<T>(path, {
|
||||
method: "PUT",
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
}, options);
|
||||
}
|
||||
|
||||
private async fetch(path: string, init: RequestInit, options?: DaemonRequestOptions): Promise<Response> {
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
// Slice 09 (OPR.0.3.2.9) — `rig policy` CLI: operator-context-mode
|
||||
// surface. Pairs with the daemon's typed-primitive store.
|
||||
//
|
||||
// Subcommands:
|
||||
// rig policy set <mode> [--scope ...] [--qualifier ...] [--<field> ...]
|
||||
// [--evidence ...] [--confirm]
|
||||
// → restate-and-confirm; PUT only when --confirm
|
||||
// rig policy show → list all bindings (defaults to JSON-when-piped)
|
||||
// rig policy effective → resolve effective for a read context
|
||||
// rig policy cite → emit the citation line per convention §Component 5
|
||||
// rig policy unset <scope> [qualifier]
|
||||
// → DELETE one binding (operator-only)
|
||||
// rig policy defaults → recommended 6×7 + per-mode scope + stale rule
|
||||
//
|
||||
// HG-4 / HG-7 anchored here:
|
||||
// - `set` never silently applies. Without `--confirm` it echoes the
|
||||
// proposed binding (mode + scope + key settings) and exits with
|
||||
// `exit 2` so scripts cannot accidentally apply. `--confirm` is the
|
||||
// explicit operator action.
|
||||
// - Mode invocation: bare word OR `mode:<mode>` prefix; both normalize
|
||||
// via disambiguateModeInvocation().
|
||||
// - Citation format: short-prose per convention §Component 5 +
|
||||
// §Citation Rules.
|
||||
|
||||
import { Command } from "commander";
|
||||
import { DaemonClient } from "../client.js";
|
||||
import { getDaemonStatus, getDaemonUrl } from "../daemon-lifecycle.js";
|
||||
import { realDeps } from "./daemon.js";
|
||||
import type { StatusDeps } from "./status.js";
|
||||
|
||||
export interface RigPolicyDeps extends StatusDeps {}
|
||||
|
||||
// Mirror of daemon enums + structure. We keep these inline so the CLI
|
||||
// doesn't grow a build-time dep on the daemon package; the validator
|
||||
// at the daemon edge is the source of truth, and the CLI sends the
|
||||
// record through unchanged.
|
||||
const MODES = ["sleep", "desk", "mobile", "away", "focus", "debug"] as const;
|
||||
type Mode = (typeof MODES)[number];
|
||||
|
||||
const SCOPES = ["global_host", "rig", "workstream", "qitem"] as const;
|
||||
type Scope = (typeof SCOPES)[number];
|
||||
|
||||
// Convention §Component 4 — bare-word disambiguation. A bare reserved
|
||||
// mode word, or `mode:<word>`, becomes an invocation; embedded-in-
|
||||
// sentence does not (the latter is a CLI input error here since the
|
||||
// CLI takes a single-positional <mode> argument).
|
||||
function disambiguateModeInvocation(raw: string): Mode | null {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const stripped = trimmed.startsWith("mode:") ? trimmed.slice("mode:".length).trim() : trimmed;
|
||||
if (stripped.split(/\s+/).length !== 1) return null;
|
||||
return (MODES as readonly string[]).includes(stripped) ? (stripped as Mode) : null;
|
||||
}
|
||||
|
||||
interface RecommendedDefaultsResponse {
|
||||
recommendedModeDefaults: Record<Mode, {
|
||||
autonomy_scope: string;
|
||||
heartbeat_cadence: string;
|
||||
inspection_depth: string;
|
||||
update_detail: string;
|
||||
escalation_threshold: string;
|
||||
concurrency_limit: string;
|
||||
permission_prompt_posture: string;
|
||||
}>;
|
||||
recommendedDefaultScope: Record<Mode, Scope>;
|
||||
defaultStaleRule: string;
|
||||
}
|
||||
|
||||
interface BindingResponse {
|
||||
binding: {
|
||||
id: string;
|
||||
mode: Mode;
|
||||
record: Record<string, string>;
|
||||
qualifier: string | null;
|
||||
setAt: string;
|
||||
setBy: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
bindings: Array<BindingResponse["binding"]>;
|
||||
}
|
||||
|
||||
interface EffectiveResponse {
|
||||
effective: { binding: BindingResponse["binding"]; resolvedScope: Scope } | null;
|
||||
posture: "known" | "unknown_posture";
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
async function withClient<T>(
|
||||
deps: RigPolicyDeps,
|
||||
fn: (client: DaemonClient) => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (status.state !== "running" || status.healthy === false) {
|
||||
console.error("Daemon not running. Start it with: rig daemon start");
|
||||
process.exitCode = 1;
|
||||
return undefined;
|
||||
}
|
||||
const client = deps.clientFactory(getDaemonUrl(status));
|
||||
return fn(client);
|
||||
}
|
||||
|
||||
function emitRecord(label: string, record: Record<string, string>): void {
|
||||
console.log(label);
|
||||
for (const [k, v] of Object.entries(record)) {
|
||||
console.log(` ${k}: ${v}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the daemon route's parseScopeAndQualifier semantics on the
|
||||
* CLI side. The CLI is the operator's authoring surface; the convention's
|
||||
* scope rule applies at invocation, NOT only at raw HTTP URL parsing.
|
||||
*
|
||||
* Reject:
|
||||
* - explicit qualifier with global_host (BLOCKING re-verify finding from
|
||||
* guard qitem-20260518044650): operators who type
|
||||
* `--scope global_host --qualifier <id>` get an error and the daemon
|
||||
* is never contacted. The CLI does NOT silently drop the qualifier.
|
||||
* - missing qualifier for any non-global scope.
|
||||
*/
|
||||
type NormalizedScope = { ok: true; qualifier: string | null } | { ok: false; message: string };
|
||||
function normalizeScopeQualifier(scope: Scope, explicitQualifier: string | undefined): NormalizedScope {
|
||||
if (scope === "global_host") {
|
||||
if (explicitQualifier !== undefined && explicitQualifier !== "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Global-host bindings cannot carry a qualifier (got "${explicitQualifier}"). Either drop --qualifier OR change --scope to rig / workstream / qitem.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: null };
|
||||
}
|
||||
if (explicitQualifier === undefined || explicitQualifier === "") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Scope ${scope} requires a qualifier (rigId / workstreamId / qitemId). Pass --qualifier <id>.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, qualifier: explicitQualifier };
|
||||
}
|
||||
|
||||
function formatCitation(b: BindingResponse["binding"]): string {
|
||||
const qualifierPart = b.qualifier ? `:${b.qualifier}` : "";
|
||||
const scope = b.record.scope as Scope;
|
||||
return `Operating in \`${b.mode}\` mode at \`${scope}${qualifierPart}\` per operator (set_at ${b.setAt})`;
|
||||
}
|
||||
|
||||
export function rigPolicyCommand(depsOverride?: RigPolicyDeps): Command {
|
||||
const cmd = new Command("policy").description(
|
||||
"Slice 09 — operator-context-mode bindings (sleep/desk/mobile/away/focus/debug × global_host/rig/workstream/qitem).",
|
||||
);
|
||||
|
||||
const getDeps = (): RigPolicyDeps =>
|
||||
depsOverride ?? {
|
||||
lifecycleDeps: realDeps(),
|
||||
clientFactory: (url: string) => new DaemonClient(url),
|
||||
};
|
||||
|
||||
// -- set ---------------------------------------------------------------
|
||||
cmd
|
||||
.command("set <mode>")
|
||||
.description(
|
||||
"Propose a mode binding. Without --confirm, restates the proposed binding and exits 2 (no daemon write). With --confirm, sets it.",
|
||||
)
|
||||
.option("--scope <scope>", `Scope: ${SCOPES.join(" | ")} (default: per-mode recommendation)`)
|
||||
.option("--qualifier <id>", "Required for rig / workstream / qitem scopes; omit for global_host")
|
||||
.option("--autonomy-scope <v>")
|
||||
.option("--heartbeat-cadence <v>")
|
||||
.option("--inspection-depth <v>")
|
||||
.option("--update-detail <v>")
|
||||
.option("--escalation-threshold <v>")
|
||||
.option("--concurrency-limit <v>")
|
||||
.option("--permission-prompt-posture <v>", "One of: normal | batch_for_human | do_not_prompt_unless_blocked (auto_accept is FORBIDDEN by convention).")
|
||||
.option("--expiry-or-stale-rule <v>")
|
||||
.option("--evidence <citation>", "Free-text citation (operator message id, file pointer, chatroom topic, etc.).")
|
||||
.option("--confirm", "Confirm the proposed binding and apply it. Without this flag, set is restate-only.")
|
||||
.option("--bearer <token>", "Operator bearer token (or set OPENRIG_AUTH_BEARER_TOKEN env).")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
modeArg: string,
|
||||
opts: {
|
||||
scope?: string;
|
||||
qualifier?: string;
|
||||
autonomyScope?: string;
|
||||
heartbeatCadence?: string;
|
||||
inspectionDepth?: string;
|
||||
updateDetail?: string;
|
||||
escalationThreshold?: string;
|
||||
concurrencyLimit?: string;
|
||||
permissionPromptPosture?: string;
|
||||
expiryOrStaleRule?: string;
|
||||
evidence?: string;
|
||||
confirm?: boolean;
|
||||
bearer?: string;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
const mode = disambiguateModeInvocation(modeArg);
|
||||
if (!mode) {
|
||||
console.error(`Unknown mode '${modeArg}'. Allowed: ${MODES.join(", ")} (or 'mode:<name>' prefix).`);
|
||||
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");
|
||||
if (defaultsRes.status >= 400) {
|
||||
console.error(JSON.stringify(defaultsRes.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const defaults = defaultsRes.data;
|
||||
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;
|
||||
}
|
||||
// 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) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const perMode = defaults.recommendedModeDefaults[mode];
|
||||
// Component 3 — exactly the 10 settings fields. `mode` is the
|
||||
// binding's identity (Component 2) and lives at the top level
|
||||
// of the PUT body, NOT inside the record.
|
||||
const record = {
|
||||
autonomy_scope: opts.autonomyScope ?? perMode.autonomy_scope,
|
||||
heartbeat_cadence: opts.heartbeatCadence ?? perMode.heartbeat_cadence,
|
||||
inspection_depth: opts.inspectionDepth ?? perMode.inspection_depth,
|
||||
update_detail: opts.updateDetail ?? perMode.update_detail,
|
||||
escalation_threshold: opts.escalationThreshold ?? perMode.escalation_threshold,
|
||||
concurrency_limit: opts.concurrencyLimit ?? perMode.concurrency_limit,
|
||||
permission_prompt_posture: opts.permissionPromptPosture ?? perMode.permission_prompt_posture,
|
||||
scope,
|
||||
expiry_or_stale_rule: opts.expiryOrStaleRule ?? defaults.defaultStaleRule,
|
||||
evidence_citation: opts.evidence ?? `operator confirmed ${mode}`,
|
||||
};
|
||||
|
||||
if (!opts.confirm) {
|
||||
// Restate-and-confirm (HG-7). No daemon write.
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, proposed: { mode, scope, qualifier, record }, confirm_required: true }, null, 2));
|
||||
} else {
|
||||
console.log(`Proposed binding (restate-and-confirm — NOT applied):`);
|
||||
console.log(` mode: ${mode}`);
|
||||
console.log(` scope: ${scope}${qualifier ? ` (${qualifier})` : ""}`);
|
||||
emitRecord(` record:`, record);
|
||||
console.log(`\nRe-run with --confirm to apply.`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const res = await client.put<BindingResponse | { error: string; errors?: string[] }>(
|
||||
`/api/rig-policy/bindings/${scope}${qualifierPath}`,
|
||||
{ mode, record },
|
||||
{ headers },
|
||||
);
|
||||
if (res.status === 401) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "unauthorized", hint: "Daemon requires operator bearer. Pass --bearer <token> or set OPENRIG_AUTH_BEARER_TOKEN." }, null, 2));
|
||||
} else {
|
||||
console.error("Unauthorized. Daemon requires an operator bearer token.");
|
||||
console.error("Pass --bearer <token> or export OPENRIG_AUTH_BEARER_TOKEN before re-running.");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(`Error setting binding (HTTP ${res.status}):`);
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const body = res.data as BindingResponse;
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, binding: body.binding }, null, 2));
|
||||
} else {
|
||||
console.log(`Set: ${body.binding.id}`);
|
||||
console.log(` mode: ${body.binding.mode}`);
|
||||
emitRecord(` record:`, body.binding.record);
|
||||
console.log(` set_by: ${body.binding.setBy}`);
|
||||
console.log(` set_at: ${body.binding.setAt}`);
|
||||
console.log(`\n${formatCitation(body.binding)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- show --------------------------------------------------------------
|
||||
cmd
|
||||
.command("show")
|
||||
.description("List all operator-context-mode bindings.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<ListResponse>("/api/rig-policy/bindings");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.bindings.length === 0) {
|
||||
console.log("No operator-context-mode bindings set.");
|
||||
return;
|
||||
}
|
||||
for (const b of res.data.bindings) {
|
||||
console.log(`${b.id} [${b.mode}] set_at=${b.setAt}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- effective ---------------------------------------------------------
|
||||
cmd
|
||||
.command("effective")
|
||||
.description("Resolve the effective mode for a (rig, workstream, qitem) read context. Q6 unknown_posture surfaced when no binding matches.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string; json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
if (res.data.posture === "unknown_posture" || !res.data.effective) {
|
||||
console.log("unknown_posture: no binding matches this read context.");
|
||||
if (res.data.hint) console.log(res.data.hint);
|
||||
return;
|
||||
}
|
||||
const b = res.data.effective.binding;
|
||||
console.log(`Effective: ${b.mode} (resolved scope: ${res.data.effective.resolvedScope})`);
|
||||
emitRecord(` record:`, b.record);
|
||||
console.log(` set_by: ${b.setBy}`);
|
||||
console.log(` set_at: ${b.setAt}`);
|
||||
});
|
||||
});
|
||||
|
||||
// -- cite --------------------------------------------------------------
|
||||
cmd
|
||||
.command("cite")
|
||||
.description("Emit a citation line for the effective mode at a read context. Per convention §Citation Rules.")
|
||||
.option("--rig <id>")
|
||||
.option("--workstream <id>")
|
||||
.option("--qitem <id>")
|
||||
.action(async (opts: { rig?: string; workstream?: string; qitem?: string }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (opts.rig) qs.set("rig", opts.rig);
|
||||
if (opts.workstream) qs.set("workstream", opts.workstream);
|
||||
if (opts.qitem) qs.set("qitem", opts.qitem);
|
||||
const path = qs.toString() ? `/api/rig-policy/effective?${qs.toString()}` : "/api/rig-policy/effective";
|
||||
const res = await client.get<EffectiveResponse>(path);
|
||||
if (res.status >= 400 || !res.data.effective) {
|
||||
console.log("Operating without an explicit operator-context-mode binding (unknown_posture).");
|
||||
return;
|
||||
}
|
||||
console.log(formatCitation(res.data.effective.binding));
|
||||
});
|
||||
});
|
||||
|
||||
// -- unset -------------------------------------------------------------
|
||||
cmd
|
||||
.command("unset <scope> [qualifier]")
|
||||
.description("Delete one binding (operator-only). Scope: global_host | rig | workstream | qitem.")
|
||||
.option("--bearer <token>")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (
|
||||
scopeArg: string,
|
||||
qualifierArg: string | undefined,
|
||||
opts: { bearer?: string; json?: boolean },
|
||||
) => {
|
||||
if (!(SCOPES as readonly string[]).includes(scopeArg)) {
|
||||
console.error(`Unknown scope '${scopeArg}'. Allowed: ${SCOPES.join(", ")}.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const scope = scopeArg as Scope;
|
||||
// BLOCKING re-verify (qitem-20260518044650): never silently drop
|
||||
// an explicit operator-supplied qualifier on global_host on unset.
|
||||
// Same hazard class as set; same shared helper.
|
||||
const normalized = normalizeScopeQualifier(scope, qualifierArg);
|
||||
if (!normalized.ok) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, error: "qualifier_invalid", hint: normalized.message }, null, 2));
|
||||
} else {
|
||||
console.error(normalized.message);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
const qualifier = normalized.qualifier;
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const qualifierPath = qualifier ? `/${encodeURIComponent(qualifier)}` : "";
|
||||
const headers: Record<string, string> = {};
|
||||
const bearer = opts.bearer ?? process.env.OPENRIG_AUTH_BEARER_TOKEN;
|
||||
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
||||
const res = await client.delete<{ removed: boolean }>(`/api/rig-policy/bindings/${scope}${qualifierPath}`, { headers });
|
||||
if (res.status >= 400) {
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: false, ...(res.data as object) }, null, 2));
|
||||
} else {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
}
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ ok: true, removed: res.data.removed }, null, 2));
|
||||
} else {
|
||||
console.log(res.data.removed ? `Removed ${scope}${qualifier ? `:${qualifier}` : ""}` : "Nothing to remove.");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -- defaults ----------------------------------------------------------
|
||||
cmd
|
||||
.command("defaults")
|
||||
.description("Print the recommended per-mode 6×7 + default-scope + stale rule.")
|
||||
.option("--json", "JSON output for agents")
|
||||
.action(async (opts: { json?: boolean }) => {
|
||||
const deps = getDeps();
|
||||
await withClient(deps, async (client) => {
|
||||
const res = await client.get<RecommendedDefaultsResponse>("/api/rig-policy/defaults");
|
||||
if (res.status >= 400) {
|
||||
console.error(JSON.stringify(res.data, null, 2));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(res.data, null, 2));
|
||||
return;
|
||||
}
|
||||
for (const mode of MODES) {
|
||||
console.log(`${mode} (default scope: ${res.data.recommendedDefaultScope[mode]})`);
|
||||
emitRecord(" ", res.data.recommendedModeDefaults[mode] as unknown as Record<string, string>);
|
||||
}
|
||||
console.log(`\ndefault stale rule: ${res.data.defaultStaleRule}`);
|
||||
});
|
||||
});
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
// Re-export for unit-testability of pure helpers.
|
||||
export const __test__ = { disambiguateModeInvocation, formatCitation, normalizeScopeQualifier };
|
||||
@@ -60,6 +60,7 @@ import { restorePacketCommand, type RestorePacketDeps } from "./commands/restore
|
||||
import { compactPlanCommand, type CompactPlanDeps } from "./commands/compact-plan.js";
|
||||
import { heartbeatCommand, type HeartbeatDeps } from "./commands/heartbeat.js";
|
||||
import { seatCommand, type SeatDeps } from "./commands/seat.js";
|
||||
import { rigPolicyCommand, type RigPolicyDeps } from "./commands/rig-policy.js";
|
||||
import type { LifecycleDeps } from "./daemon-lifecycle.js";
|
||||
import { CLI_VERSION } from "./version.js";
|
||||
|
||||
@@ -115,6 +116,7 @@ export interface ProgramDeps {
|
||||
compactPlanDeps?: CompactPlanDeps;
|
||||
heartbeatDeps?: HeartbeatDeps;
|
||||
seatDeps?: SeatDeps;
|
||||
rigPolicyDeps?: RigPolicyDeps;
|
||||
configPath?: string;
|
||||
}
|
||||
|
||||
@@ -164,6 +166,7 @@ export function createProgram(depsOverride?: ProgramDeps): Command {
|
||||
program.addCommand(pluginCommand(depsOverride?.pluginDeps));
|
||||
program.addCommand(agentImageCommand(depsOverride?.agentImageDeps));
|
||||
program.addCommand(workspaceCommand(depsOverride?.workspaceDeps));
|
||||
program.addCommand(rigPolicyCommand(depsOverride?.rigPolicyDeps));
|
||||
program.addCommand(whoamiCommand(depsOverride?.whoamiDeps));
|
||||
program.addCommand(configCommand(depsOverride?.configPath));
|
||||
program.addCommand(preflightCommand());
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
// Slice 09 (OPR.0.3.2.9) — `rig policy` CLI tests.
|
||||
//
|
||||
// HG-4 / HG-7 anchored at the CLI layer:
|
||||
// - HG-7: `rig policy set <mode>` WITHOUT --confirm restates the
|
||||
// proposed binding and exits 2; daemon is NOT called.
|
||||
// - HG-7: bare-word + `mode:` prefix BOTH normalize to a valid mode.
|
||||
// - HG-4: with --confirm + --bearer, an Authorization header is sent
|
||||
// on the PUT.
|
||||
// - HG-3: `rig policy effective` surfaces Q6 unknown_posture cleanly
|
||||
// when no binding matches.
|
||||
// - Citation helper format matches convention §Citation Rules
|
||||
// (short-prose with mode + scope(:qualifier) + operator source).
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { rigPolicyCommand, __test__, type RigPolicyDeps } from "../src/commands/rig-policy.js";
|
||||
import type { LifecycleDeps } from "../src/daemon-lifecycle.js";
|
||||
|
||||
vi.mock("../src/daemon-lifecycle.js", async () => {
|
||||
const actual = await vi.importActual<Record<string, unknown>>("../src/daemon-lifecycle.js");
|
||||
return {
|
||||
...actual,
|
||||
getDaemonStatus: vi.fn(async () => ({ state: "running", healthy: true, pid: 1, port: 7433 })),
|
||||
getDaemonUrl: vi.fn(() => "http://localhost:7433"),
|
||||
};
|
||||
});
|
||||
|
||||
interface FakeResponse<T = unknown> { status: number; data: T }
|
||||
type RecordedCall = { method: string; path: string; body?: unknown; options?: { headers?: Record<string, string> } };
|
||||
|
||||
function fakeClient(opts: {
|
||||
defaultsResponse?: FakeResponse;
|
||||
putResponse?: FakeResponse;
|
||||
deleteResponse?: FakeResponse;
|
||||
getResponse?: FakeResponse;
|
||||
effectiveResponse?: FakeResponse;
|
||||
}): { client: unknown; calls: RecordedCall[] } {
|
||||
const calls: RecordedCall[] = [];
|
||||
const client = {
|
||||
get: vi.fn(async (path: string) => {
|
||||
calls.push({ method: "GET", path });
|
||||
if (path.startsWith("/api/rig-policy/defaults")) {
|
||||
return opts.defaultsResponse ?? {
|
||||
status: 200,
|
||||
data: {
|
||||
recommendedModeDefaults: {
|
||||
sleep: { autonomy_scope: "pre_approved_only", heartbeat_cadence: "sparse", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "serial", permission_prompt_posture: "batch_for_human" },
|
||||
desk: { autonomy_scope: "full_autonomy_within_workstream", heartbeat_cadence: "normal", inspection_depth: "normal", update_detail: "normal", escalation_threshold: "normal", concurrency_limit: "unlimited", permission_prompt_posture: "normal" },
|
||||
mobile: { autonomy_scope: "bounded_continuation", heartbeat_cadence: "normal", inspection_depth: "surface", update_detail: "compact", escalation_threshold: "low", concurrency_limit: "unlimited", permission_prompt_posture: "batch_for_human" },
|
||||
away: { autonomy_scope: "pre_approved_only", heartbeat_cadence: "sparse", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "serial", permission_prompt_posture: "batch_for_human" },
|
||||
focus: { autonomy_scope: "full_autonomy_within_workstream", heartbeat_cadence: "normal", inspection_depth: "normal", update_detail: "compact", escalation_threshold: "blocker_only", concurrency_limit: "unlimited", permission_prompt_posture: "batch_for_human" },
|
||||
debug: { autonomy_scope: "bounded_continuation", heartbeat_cadence: "fast", inspection_depth: "forensic", update_detail: "verbose", escalation_threshold: "low", concurrency_limit: "serial", permission_prompt_posture: "normal" },
|
||||
},
|
||||
recommendedDefaultScope: { sleep: "global_host", desk: "global_host", mobile: "global_host", away: "global_host", focus: "workstream", debug: "qitem" },
|
||||
defaultStaleRule: "re_confirm_on_long_gap",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (path.startsWith("/api/rig-policy/effective")) {
|
||||
return opts.effectiveResponse ?? { status: 200, data: { effective: null, posture: "unknown_posture", hint: "..." } };
|
||||
}
|
||||
return opts.getResponse ?? { status: 200, data: { bindings: [] } };
|
||||
}),
|
||||
put: vi.fn(async (path: string, body: unknown, options?: { headers?: Record<string, string> }) => {
|
||||
calls.push({ method: "PUT", path, body, options });
|
||||
const reqBody = body as { mode: string; record: Record<string, string> };
|
||||
return opts.putResponse ?? { status: 200, data: { binding: { id: "qitem:q-1", mode: reqBody.mode, record: reqBody.record, qualifier: "q-1", setAt: "2026-05-17T00:00:00.000Z", setBy: "operator" } } };
|
||||
}),
|
||||
delete: vi.fn(async (path: string, options?: { headers?: Record<string, string> }) => {
|
||||
calls.push({ method: "DELETE", path, options });
|
||||
return opts.deleteResponse ?? { status: 200, data: { removed: true } };
|
||||
}),
|
||||
};
|
||||
return { client, calls };
|
||||
}
|
||||
|
||||
function deps(client: unknown): RigPolicyDeps {
|
||||
return {
|
||||
lifecycleDeps: {} as LifecycleDeps,
|
||||
clientFactory: () => client as ReturnType<RigPolicyDeps["clientFactory"]>,
|
||||
};
|
||||
}
|
||||
|
||||
let logs: string[];
|
||||
let errs: string[];
|
||||
beforeEach(() => {
|
||||
logs = [];
|
||||
errs = [];
|
||||
vi.spyOn(console, "log").mockImplementation((...args) => logs.push(args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")));
|
||||
vi.spyOn(console, "error").mockImplementation((...args) => errs.push(args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")));
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
describe("disambiguateModeInvocation (Component 4 bare-word rule)", () => {
|
||||
const { disambiguateModeInvocation } = __test__;
|
||||
it("accepts bare reserved modes", () => {
|
||||
for (const m of ["sleep", "desk", "mobile", "away", "focus", "debug"]) {
|
||||
expect(disambiguateModeInvocation(m)).toBe(m);
|
||||
}
|
||||
});
|
||||
it("accepts the `mode:` prefix form", () => {
|
||||
expect(disambiguateModeInvocation("mode:debug")).toBe("debug");
|
||||
expect(disambiguateModeInvocation("mode: focus")).toBe("focus");
|
||||
});
|
||||
it("rejects unknown words", () => {
|
||||
expect(disambiguateModeInvocation("banana")).toBeNull();
|
||||
expect(disambiguateModeInvocation("operator:L1")).toBeNull();
|
||||
expect(disambiguateModeInvocation("OFF")).toBeNull();
|
||||
});
|
||||
it("rejects multi-word strings (embedded-in-sentence)", () => {
|
||||
expect(disambiguateModeInvocation("set debug")).toBeNull();
|
||||
expect(disambiguateModeInvocation("we should mobile this")).toBeNull();
|
||||
});
|
||||
it("rejects empty input", () => {
|
||||
expect(disambiguateModeInvocation("")).toBeNull();
|
||||
expect(disambiguateModeInvocation(" ")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCitation (Component 5)", () => {
|
||||
const { formatCitation } = __test__;
|
||||
it("emits mode + scope(:qualifier) + operator source + set_at — mode is binding-level", () => {
|
||||
const line = formatCitation({
|
||||
id: "qitem:q-1",
|
||||
mode: "debug",
|
||||
record: { scope: "qitem" } as Record<string, string>,
|
||||
qualifier: "q-1",
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
});
|
||||
expect(line).toContain("`debug`");
|
||||
expect(line).toContain("`qitem:q-1`");
|
||||
expect(line).toContain("operator");
|
||||
expect(line).toContain("2026-05-17T00:00:00.000Z");
|
||||
});
|
||||
it("omits qualifier when null (global_host)", () => {
|
||||
const line = formatCitation({
|
||||
id: "global_host:host",
|
||||
mode: "sleep",
|
||||
record: { scope: "global_host" } as Record<string, string>,
|
||||
qualifier: null,
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
});
|
||||
expect(line).toContain("`global_host`");
|
||||
expect(line).not.toContain("`global_host:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy set — restate-and-confirm gate (HG-7)", () => {
|
||||
it("WITHOUT --confirm: restates + exits 2 + does NOT call PUT", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
const puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(puts).toHaveLength(0);
|
||||
expect(logs.join("\n")).toContain("Proposed binding (restate-and-confirm — NOT applied)");
|
||||
expect(logs.join("\n")).toContain("debug");
|
||||
});
|
||||
|
||||
it("WITHOUT --confirm + --json: ok:false + confirm_required:true", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--json"]);
|
||||
expect(process.exitCode).toBe(2);
|
||||
const joined = logs.join("\n");
|
||||
const parsed = JSON.parse(joined);
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.confirm_required).toBe(true);
|
||||
expect(parsed.proposed.mode).toBe("debug");
|
||||
expect(parsed.proposed.record.mode).toBeUndefined();
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("WITH --confirm: PUTs { mode, record } to the daemon and prints citation", 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 puts = calls.filter((c) => c.method === "PUT");
|
||||
expect(puts).toHaveLength(1);
|
||||
expect(puts[0]!.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
const putBody = puts[0]!.body as { mode: string; record: Record<string, string> };
|
||||
expect(putBody.mode).toBe("debug");
|
||||
// BLOCKING-1: record must NOT carry `mode` inside.
|
||||
expect(putBody.record.mode).toBeUndefined();
|
||||
expect(putBody.record.scope).toBe("qitem");
|
||||
expect(logs.join("\n")).toContain("Operating in `debug` mode at `qitem:q-1`");
|
||||
});
|
||||
|
||||
it("HG-4: --bearer forwards Authorization: Bearer <token>", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm", "--bearer", "operator-token"]);
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
expect(put.options?.headers?.Authorization).toBe("Bearer operator-token");
|
||||
});
|
||||
|
||||
it("HG-7 mode:<name> prefix: works the same as bare word", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "mode:focus", "--qualifier", "ws-1", "--confirm"]);
|
||||
const put = calls.find((c) => c.method === "PUT")!;
|
||||
const body = put.body as { mode: string; record: Record<string, string> };
|
||||
expect(body.mode).toBe("focus");
|
||||
expect(body.record.scope).toBe("workstream");
|
||||
expect(put.path).toBe("/api/rig-policy/bindings/workstream/ws-1");
|
||||
});
|
||||
|
||||
it("rejects unknown mode with exit=1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "banana", "--qualifier", "q-1"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "PUT")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Unknown mode/);
|
||||
});
|
||||
|
||||
it("forwards 401 from daemon with operator hint", async () => {
|
||||
const { client } = fakeClient({ putResponse: { status: 401, data: { error: "unauthorized" } } });
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "set", "debug", "--qualifier", "q-1", "--confirm"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(errs.join("\n")).toMatch(/Unauthorized/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy effective — Q6 unknown_posture surfaced", () => {
|
||||
it("prints 'unknown_posture' when daemon returns null effective", async () => {
|
||||
const { client } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "effective", "--qitem", "q-1"]);
|
||||
expect(logs.join("\n")).toContain("unknown_posture");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rig policy cite — convention citation rules", () => {
|
||||
it("prints unknown_posture fallback when no binding resolves", async () => {
|
||||
const { client } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "cite", "--qitem", "q-1"]);
|
||||
expect(logs.join("\n")).toContain("without an explicit");
|
||||
});
|
||||
|
||||
it("emits citation line when a binding resolves", async () => {
|
||||
const { client } = fakeClient({
|
||||
effectiveResponse: {
|
||||
status: 200,
|
||||
data: {
|
||||
posture: "known",
|
||||
effective: {
|
||||
resolvedScope: "qitem",
|
||||
binding: {
|
||||
id: "qitem:q-1",
|
||||
mode: "debug",
|
||||
record: { scope: "qitem" },
|
||||
qualifier: "q-1",
|
||||
setAt: "2026-05-17T00:00:00.000Z",
|
||||
setBy: "operator",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "cite", "--qitem", "q-1"]);
|
||||
const line = logs.join("\n");
|
||||
expect(line).toMatch(/Operating in `debug` mode at `qitem:q-1` per operator/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeScopeQualifier (CLI mirror of daemon-route parseScopeAndQualifier)", () => {
|
||||
const { normalizeScopeQualifier } = __test__;
|
||||
it("global_host + no explicit qualifier → ok with null", () => {
|
||||
const r = normalizeScopeQualifier("global_host", undefined);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.qualifier).toBeNull();
|
||||
});
|
||||
it("global_host + explicit qualifier → REJECTED (no silent drop)", () => {
|
||||
const r = normalizeScopeQualifier("global_host", "unexpected");
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.message).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
it("rig + qualifier → ok with the qualifier", () => {
|
||||
const r = normalizeScopeQualifier("rig", "rig-a");
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.qualifier).toBe("rig-a");
|
||||
});
|
||||
it("rig + no qualifier → rejected (qualifier_required)", () => {
|
||||
const r = normalizeScopeQualifier("rig", undefined);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.message).toMatch(/requires a qualifier/);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
// 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/);
|
||||
expect(logs.join("\n")).not.toContain("Proposed binding");
|
||||
});
|
||||
|
||||
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).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
|
||||
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).toHaveLength(0);
|
||||
const parsed = JSON.parse(logs.join("\n"));
|
||||
expect(parsed.ok).toBe(false);
|
||||
expect(parsed.error).toBe("qualifier_invalid");
|
||||
});
|
||||
|
||||
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"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
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", () => {
|
||||
it("calls DELETE with Authorization when --bearer is provided", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "qitem", "q-1", "--bearer", "operator-token"]);
|
||||
const del = calls.find((c) => c.method === "DELETE")!;
|
||||
expect(del.path).toBe("/api/rig-policy/bindings/qitem/q-1");
|
||||
expect(del.options?.headers?.Authorization).toBe("Bearer operator-token");
|
||||
});
|
||||
|
||||
it("rejects unknown scope with exit=1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "banana", "x"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0);
|
||||
});
|
||||
|
||||
// BLOCKING re-verify (qitem-20260518044650): `unset global_host
|
||||
// <qualifier>` must NOT silently drop the qualifier and delete the
|
||||
// host row. Same shape as set; same shared helper.
|
||||
it("BLOCKING re-verify: unset global_host unexpected → exits 1; daemon NOT called", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "global_host", "unexpected"]);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(calls.filter((c) => c.method === "DELETE")).toHaveLength(0);
|
||||
expect(errs.join("\n")).toMatch(/Global-host bindings cannot carry a qualifier/);
|
||||
});
|
||||
|
||||
it("Positive: unset global_host (no qualifier) targets /bindings/global_host", async () => {
|
||||
const { client, calls } = fakeClient({});
|
||||
const cmd = rigPolicyCommand(deps(client));
|
||||
await cmd.parseAsync(["node", "rig", "unset", "global_host"]);
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const del = calls.find((c) => c.method === "DELETE")!;
|
||||
expect(del.path).toBe("/api/rig-policy/bindings/global_host");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Migration } from "../migrate.js";
|
||||
|
||||
/**
|
||||
* Slice 09 Rig Policy Primitive (OPR.0.3.2.9) — operator-context-mode
|
||||
* binding table.
|
||||
*
|
||||
* One row per scoped binding:
|
||||
* (scope, qualifier) is a unique key; multiple modes at different
|
||||
* scopes coexist (e.g., sleep@global_host + debug@qitem). The
|
||||
* effective-mode resolver picks the most-specific applicable row
|
||||
* for a (rig, workstream, qitem) read context.
|
||||
*
|
||||
* Schema follows the workspace-primitive migration pattern: small
|
||||
* table, JSON column for the 10-field record (the validator owns
|
||||
* its integrity at write time). This matches the typed-primitive
|
||||
* precedent the slice IMPL-PRD names (HG-5: no parallel store).
|
||||
*
|
||||
* `qualifier` is NULL for `scope = 'global_host'` and carries the
|
||||
* appropriate id for the other three scopes.
|
||||
*
|
||||
* `set_at` is a UTC ISO string written by the daemon. The drift
|
||||
* mechanism reads this to compute long-gap re-confirmation prompts
|
||||
* (v0 ships the field; the numeric threshold is convention Q3
|
||||
* follow-on).
|
||||
*
|
||||
* `set_by` is always `'operator'` in v0 — there is no agent-set path.
|
||||
* The column exists so future operator-attribution work (e.g.,
|
||||
* multi-operator hosts) can shape per-operator views without a
|
||||
* schema migration; v0's validator + route enforce the operator-only
|
||||
* contract at the boundary.
|
||||
*/
|
||||
export const rigPolicySchema: Migration = {
|
||||
name: "041_rig_policy.sql",
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS operator_context_mode_bindings (
|
||||
id TEXT PRIMARY KEY,
|
||||
scope TEXT NOT NULL CHECK (scope IN ('global_host', 'rig', 'workstream', 'qitem')),
|
||||
qualifier TEXT,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('sleep', 'desk', 'mobile', 'away', 'focus', 'debug')),
|
||||
record_json TEXT NOT NULL,
|
||||
set_at TEXT NOT NULL,
|
||||
set_by TEXT NOT NULL CHECK (set_by = 'operator')
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_operator_context_mode_bindings_scope_qualifier
|
||||
ON operator_context_mode_bindings(scope, qualifier);
|
||||
`,
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
// Slice 09 — recommended per-mode defaults + per-mode default scope.
|
||||
//
|
||||
// Component 3 — Recommended Per-Mode Defaults (the 6×7 matrix).
|
||||
// Component 4 — Recommended Default Scopes Per Mode.
|
||||
//
|
||||
// "RECOMMENDED, operator-overridable per field" — these are the
|
||||
// fall-back values the CLI / HTTP set route applies when the operator
|
||||
// invokes a bare mode (e.g., `rig policy set debug`) without
|
||||
// specifying individual fields. Operator-supplied per-field overrides
|
||||
// merge over the defaults.
|
||||
|
||||
import type {
|
||||
AutonomyScope,
|
||||
ConcurrencyLimit,
|
||||
EscalationThreshold,
|
||||
HeartbeatCadence,
|
||||
InspectionDepth,
|
||||
OperatorContextMode,
|
||||
OperatorContextScope,
|
||||
PermissionPromptPosture,
|
||||
UpdateDetail,
|
||||
} from "./rig-policy-types.js";
|
||||
|
||||
/**
|
||||
* The seven per-mode setting defaults (Component 3 §Recommended
|
||||
* Per-Mode Defaults). Mirrors the convention table verbatim.
|
||||
*
|
||||
* `scope` is NOT included here — per-mode default scope lives in
|
||||
* RECOMMENDED_DEFAULT_SCOPE below, since the convention treats it
|
||||
* separately (Component 4 §Recommended Default Scopes Per Mode).
|
||||
*
|
||||
* `expiry_or_stale_rule` and `evidence_citation` are not part of the
|
||||
* 6×7 matrix; the daemon applies a conservative default for the
|
||||
* former (`re_confirm_on_long_gap` — convention Q3 leaves the numeric
|
||||
* for Mode 2; the rule kind is the v0 conservative choice) and the
|
||||
* latter is operator-provided per invocation.
|
||||
*/
|
||||
export interface RecommendedModeDefaults {
|
||||
autonomy_scope: AutonomyScope;
|
||||
heartbeat_cadence: HeartbeatCadence;
|
||||
inspection_depth: InspectionDepth;
|
||||
update_detail: UpdateDetail;
|
||||
escalation_threshold: EscalationThreshold;
|
||||
concurrency_limit: ConcurrencyLimit;
|
||||
permission_prompt_posture: PermissionPromptPosture;
|
||||
}
|
||||
|
||||
export const RECOMMENDED_MODE_DEFAULTS: Record<OperatorContextMode, RecommendedModeDefaults> = {
|
||||
sleep: {
|
||||
autonomy_scope: "pre_approved_only",
|
||||
heartbeat_cadence: "sparse",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "blocker_only",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
desk: {
|
||||
autonomy_scope: "full_autonomy_within_workstream",
|
||||
heartbeat_cadence: "normal",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "normal",
|
||||
escalation_threshold: "normal",
|
||||
concurrency_limit: "unlimited", // "normal" in the convention table; v0 maps "normal" concurrency to unlimited (the existing OpenRig default — agents fan out per workstream-mode discipline)
|
||||
permission_prompt_posture: "normal",
|
||||
},
|
||||
mobile: {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "normal",
|
||||
inspection_depth: "surface",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "unlimited",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
away: {
|
||||
autonomy_scope: "pre_approved_only",
|
||||
heartbeat_cadence: "sparse",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "blocker_only",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
focus: {
|
||||
autonomy_scope: "full_autonomy_within_workstream",
|
||||
heartbeat_cadence: "normal",
|
||||
inspection_depth: "normal",
|
||||
update_detail: "compact",
|
||||
escalation_threshold: "blocker_only",
|
||||
concurrency_limit: "unlimited",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
},
|
||||
debug: {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "fast",
|
||||
inspection_depth: "forensic",
|
||||
update_detail: "verbose",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Component 4 — Recommended Default Scopes Per Mode.
|
||||
*
|
||||
* Used during restate-and-confirm: when the operator's invocation
|
||||
* omits an explicit scope, the agent restates with this recommendation
|
||||
* and awaits confirmation/correction. The operator's confirmation is
|
||||
* authoritative.
|
||||
*/
|
||||
export const RECOMMENDED_DEFAULT_SCOPE: Record<OperatorContextMode, OperatorContextScope> = {
|
||||
sleep: "global_host",
|
||||
away: "global_host",
|
||||
desk: "global_host",
|
||||
mobile: "global_host",
|
||||
focus: "workstream",
|
||||
debug: "qitem",
|
||||
};
|
||||
|
||||
/**
|
||||
* Conservative default for `expiry_or_stale_rule`. Per convention Q3,
|
||||
* the numeric threshold is deferred to a Mode 2 helper slice;
|
||||
* `re_confirm_on_long_gap` is the rule kind selected here as the
|
||||
* safest default (it prompts re-confirmation rather than silent
|
||||
* continuation when the operator has been away for an extended
|
||||
* period).
|
||||
*
|
||||
* Operators may override per-binding via the `expiry_or_stale_rule`
|
||||
* field of the OperatorContextModeRecord; v0 does not auto-tune the
|
||||
* threshold.
|
||||
*/
|
||||
export const DEFAULT_STALE_RULE: "re_confirm_on_long_gap" = "re_confirm_on_long_gap";
|
||||
@@ -0,0 +1,226 @@
|
||||
// Slice 09 — daemon-persisted typed primitive (SQLite-backed).
|
||||
//
|
||||
// Follows the workspace-primitive precedent: structured table,
|
||||
// JSON column for the 10-field record (validator owns integrity at
|
||||
// write time). NO parallel/forked store (HG-5).
|
||||
//
|
||||
// The store exposes:
|
||||
// - setBinding(scope, qualifier, mode, record) → upserts a row
|
||||
// (mode is binding-level
|
||||
// Component-2 identity;
|
||||
// record is the frozen
|
||||
// 10-field Component-3
|
||||
// settings)
|
||||
// - getBinding(scope, qualifier) → reads one row
|
||||
// - listBindings() → all rows (for show)
|
||||
// - resolveEffective(readContext) → most-specific binding
|
||||
// - deleteBinding(scope, qualifier) → unset
|
||||
//
|
||||
// Update authority is operator-only: setBinding hard-codes
|
||||
// `set_by = 'operator'`. The HTTP route + CLI are the only authoring
|
||||
// surfaces; agent code paths read but never write. (HG-4)
|
||||
//
|
||||
// HG-SAFE preserved here: this store NEVER writes to permission
|
||||
// allowlists / runtime configs / auth / tmux / lifecycle. It writes
|
||||
// to ONE table (operator_context_mode_bindings) and reads JSON. A
|
||||
// future contributor cannot widen permission authority via this
|
||||
// service because the surface area is the bindings table only.
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
import {
|
||||
type EffectiveOperatorContextMode,
|
||||
type OperatorContextMode,
|
||||
type OperatorContextModeBinding,
|
||||
type OperatorContextModeRecord,
|
||||
type OperatorContextReadContext,
|
||||
type OperatorContextScope,
|
||||
SCOPE_SPECIFICITY,
|
||||
} from "./rig-policy-types.js";
|
||||
import { validateModeName, validateRecord } from "./rig-policy-validator.js";
|
||||
|
||||
interface BindingRow {
|
||||
id: string;
|
||||
scope: OperatorContextScope;
|
||||
qualifier: string | null;
|
||||
mode: OperatorContextMode;
|
||||
record_json: string;
|
||||
set_at: string;
|
||||
set_by: string;
|
||||
}
|
||||
|
||||
function rowToBinding(row: BindingRow): OperatorContextModeBinding {
|
||||
return {
|
||||
id: row.id,
|
||||
mode: row.mode,
|
||||
record: JSON.parse(row.record_json) as OperatorContextModeRecord,
|
||||
qualifier: row.qualifier,
|
||||
setAt: row.set_at,
|
||||
setBy: "operator",
|
||||
};
|
||||
}
|
||||
|
||||
function bindingId(scope: OperatorContextScope, qualifier: string | null): string {
|
||||
return `${scope}:${qualifier ?? "host"}`;
|
||||
}
|
||||
|
||||
export interface SetBindingResult {
|
||||
ok: true;
|
||||
binding: OperatorContextModeBinding;
|
||||
}
|
||||
|
||||
export interface SetBindingError {
|
||||
ok: false;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface RigPolicyStoreOpts {
|
||||
/** Override the clock for tests; defaults to new Date(). */
|
||||
now?: () => Date;
|
||||
}
|
||||
|
||||
export class RigPolicyStore {
|
||||
private readonly db: Database.Database;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(db: Database.Database, opts?: RigPolicyStoreOpts) {
|
||||
this.db = db;
|
||||
this.now = opts?.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-only set. Validates the candidate record + the
|
||||
* scope/qualifier shape, then upserts. Returns the typed binding
|
||||
* on success.
|
||||
*
|
||||
* scope == 'global_host' REQUIRES qualifier === null;
|
||||
* scope != 'global_host' REQUIRES a non-empty qualifier string.
|
||||
* Both are operator/host invariants — the route validates and
|
||||
* the store rejects on disagreement.
|
||||
*/
|
||||
setBinding(
|
||||
scope: OperatorContextScope,
|
||||
qualifier: string | null,
|
||||
mode: unknown,
|
||||
candidateRecord: unknown,
|
||||
): SetBindingResult | SetBindingError {
|
||||
const errors: string[] = [];
|
||||
if (scope === "global_host" && qualifier !== null) {
|
||||
errors.push(
|
||||
`Global-host bindings cannot carry a qualifier (got ${JSON.stringify(qualifier)}). Pass null for the global_host scope.`,
|
||||
);
|
||||
}
|
||||
if (scope !== "global_host" && (typeof qualifier !== "string" || qualifier.length === 0)) {
|
||||
errors.push(
|
||||
`${scope} bindings require a qualifier (rigId / workstreamId / qitemId). Got ${JSON.stringify(qualifier)}.`,
|
||||
);
|
||||
}
|
||||
const modeCheck = validateModeName(mode);
|
||||
if (!modeCheck.ok) errors.push(modeCheck.error);
|
||||
const validation = validateRecord(candidateRecord);
|
||||
if (!validation.ok) errors.push(...validation.errors);
|
||||
if (errors.length > 0 || !modeCheck.ok || !validation.ok) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
const record = validation.record;
|
||||
if (record.scope !== scope) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
`Record scope mismatch: binding scope is "${scope}" but record.scope is "${record.scope}". The record's scope field MUST agree with the binding scope.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const id = bindingId(scope, qualifier);
|
||||
const setAt = this.now().toISOString();
|
||||
this.db.prepare(`
|
||||
INSERT INTO operator_context_mode_bindings (id, scope, qualifier, mode, record_json, set_at, set_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'operator')
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
mode = excluded.mode,
|
||||
record_json = excluded.record_json,
|
||||
set_at = excluded.set_at
|
||||
`).run(id, scope, qualifier, modeCheck.mode, JSON.stringify(record), setAt);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
binding: {
|
||||
id,
|
||||
mode: modeCheck.mode,
|
||||
record,
|
||||
qualifier,
|
||||
setAt,
|
||||
setBy: "operator",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getBinding(
|
||||
scope: OperatorContextScope,
|
||||
qualifier: string | null,
|
||||
): OperatorContextModeBinding | null {
|
||||
const row = this.db.prepare(`
|
||||
SELECT id, scope, qualifier, mode, record_json, set_at, set_by
|
||||
FROM operator_context_mode_bindings
|
||||
WHERE id = ?
|
||||
`).get(bindingId(scope, qualifier)) as BindingRow | undefined;
|
||||
return row ? rowToBinding(row) : null;
|
||||
}
|
||||
|
||||
listBindings(): OperatorContextModeBinding[] {
|
||||
const rows = this.db.prepare(`
|
||||
SELECT id, scope, qualifier, mode, record_json, set_at, set_by
|
||||
FROM operator_context_mode_bindings
|
||||
ORDER BY scope, qualifier NULLS FIRST, id
|
||||
`).all() as BindingRow[];
|
||||
return rows.map(rowToBinding);
|
||||
}
|
||||
|
||||
deleteBinding(scope: OperatorContextScope, qualifier: string | null): boolean {
|
||||
const info = this.db.prepare(`
|
||||
DELETE FROM operator_context_mode_bindings WHERE id = ?
|
||||
`).run(bindingId(scope, qualifier));
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most-specific applicable binding for a read context.
|
||||
* Returns null when no binding matches — callers MUST treat null as
|
||||
* `unknown_posture` per convention §Q6 (do NOT default to `desk`).
|
||||
*
|
||||
* Specificity ranks (qitem > workstream > rig > global_host) are
|
||||
* defined in rig-policy-types.SCOPE_SPECIFICITY. For each scope the
|
||||
* resolver picks the binding whose qualifier matches the read
|
||||
* context. Among multiple matching bindings, the most-specific
|
||||
* scope wins.
|
||||
*/
|
||||
resolveEffective(ctx: OperatorContextReadContext): EffectiveOperatorContextMode | null {
|
||||
const candidates: OperatorContextModeBinding[] = [];
|
||||
if (ctx.qitemId) {
|
||||
const b = this.getBinding("qitem", ctx.qitemId);
|
||||
if (b) candidates.push(b);
|
||||
}
|
||||
if (ctx.workstreamId) {
|
||||
const b = this.getBinding("workstream", ctx.workstreamId);
|
||||
if (b) candidates.push(b);
|
||||
}
|
||||
if (ctx.rigId) {
|
||||
const b = this.getBinding("rig", ctx.rigId);
|
||||
if (b) candidates.push(b);
|
||||
}
|
||||
const host = this.getBinding("global_host", null);
|
||||
if (host) candidates.push(host);
|
||||
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
candidates.sort(
|
||||
(a, b) => SCOPE_SPECIFICITY[b.record.scope] - SCOPE_SPECIFICITY[a.record.scope],
|
||||
);
|
||||
const winner = candidates[0]!;
|
||||
return {
|
||||
binding: winner,
|
||||
resolvedScope: winner.record.scope,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Slice 09 — Rig Policy Primitive (OPR.0.3.2.9).
|
||||
//
|
||||
// Typed graduation of the operator-context-mode-system v0 doctrine. The
|
||||
// convention at conventions/operator-context-mode-system/README.md is
|
||||
// the FROZEN spec; types below mirror it as closed enums + a 10-field
|
||||
// schema. None of these fields may be silently merged or dropped — the
|
||||
// validator enforces field-set integrity.
|
||||
//
|
||||
// GATE-ZERO (HG-SAFE): this primitive is permission-ADJACENT but NEVER
|
||||
// permission-modifying. `permission_prompt_posture` is a descriptive
|
||||
// operator-set ergonomic hint. The enum below is STRUCTURALLY
|
||||
// incapable of expressing auto-accept: there is no auto-accept member
|
||||
// in the union, and the validator further rejects unknown values. Any
|
||||
// future contributor who tries to add an auto-accept value here MUST
|
||||
// also amend the permission-posture convention + safety policy in the
|
||||
// FROZEN contract — review will reject otherwise.
|
||||
|
||||
/**
|
||||
* Component 2 — six reserved mode names. Lowercase single English
|
||||
* words; synonyms and numeric aliases (`L0`–`L3`, `operator:L<n>`)
|
||||
* are explicitly forbidden by the convention. The L0–L3 collision
|
||||
* warning at `conventions/operator-context-mode-system/README.md`
|
||||
* §"L0–L3 Collision Warning" is load-bearing.
|
||||
*/
|
||||
export type OperatorContextMode = "sleep" | "desk" | "mobile" | "away" | "focus" | "debug";
|
||||
|
||||
export const OPERATOR_CONTEXT_MODES = [
|
||||
"sleep",
|
||||
"desk",
|
||||
"mobile",
|
||||
"away",
|
||||
"focus",
|
||||
"debug",
|
||||
] as const satisfies readonly OperatorContextMode[];
|
||||
|
||||
/**
|
||||
* Component 4 — four scopes. More-specific overrides less-specific
|
||||
* when multiple modes coexist (Scope hierarchy: qitem > workstream >
|
||||
* rig > global_host).
|
||||
*/
|
||||
export type OperatorContextScope = "global_host" | "rig" | "workstream" | "qitem";
|
||||
|
||||
export const OPERATOR_CONTEXT_SCOPES = [
|
||||
"global_host",
|
||||
"rig",
|
||||
"workstream",
|
||||
"qitem",
|
||||
] as const satisfies readonly OperatorContextScope[];
|
||||
|
||||
/**
|
||||
* Scope specificity ranks; higher number = more specific. Used by the
|
||||
* store's effective-mode resolver. NOT operator-facing.
|
||||
*/
|
||||
export const SCOPE_SPECIFICITY: Record<OperatorContextScope, number> = {
|
||||
global_host: 0,
|
||||
rig: 1,
|
||||
workstream: 2,
|
||||
qitem: 3,
|
||||
};
|
||||
|
||||
// --- 10-field schema enums ---
|
||||
|
||||
export type AutonomyScope =
|
||||
| "pre_approved_only"
|
||||
| "bounded_continuation"
|
||||
| "full_autonomy_within_workstream"
|
||||
| "full_autonomy";
|
||||
|
||||
export type HeartbeatCadence = "sparse" | "normal" | "fast";
|
||||
|
||||
export type InspectionDepth = "surface" | "normal" | "forensic";
|
||||
|
||||
export type UpdateDetail = "compact" | "normal" | "verbose";
|
||||
|
||||
export type EscalationThreshold = "low" | "normal" | "high" | "blocker_only";
|
||||
|
||||
/**
|
||||
* Component 3 — concurrency_limit. Suggested values include the enum
|
||||
* forms below + structured integer values (1..N). v0 ships the enum
|
||||
* subset for simplicity; an integer specialization can graduate via
|
||||
* Mode 1.5 amendment when fixture-backed evidence appears.
|
||||
*/
|
||||
export type ConcurrencyLimit = "serial" | "2" | "4" | "unlimited";
|
||||
|
||||
/**
|
||||
* Component 6 — Safety Policy load-bearing rule:
|
||||
* `permission_prompt_posture` MUST NOT include auto-accept in v0 or
|
||||
* any descendant. The safe values are exactly these three.
|
||||
*
|
||||
* The enum is STRUCTURALLY closed: no auto-accept literal exists in
|
||||
* the union. A contributor cannot "set permissionPromptPosture =
|
||||
* 'auto_accept'" because the literal isn't a member; TypeScript
|
||||
* rejects at compile time. The validator additionally rejects any
|
||||
* non-member string at runtime (defense-in-depth for inputs that
|
||||
* bypass typing — JSON file, env var, etc).
|
||||
*
|
||||
* If a future amendment proposes auto-accept, it MUST first amend
|
||||
* the permission-posture canon at
|
||||
* `conventions/permission-posture/README.md` AND the FROZEN
|
||||
* operator-context-mode safety policy. This slice and its
|
||||
* descendants reject auto-accept independently of any caller intent.
|
||||
*/
|
||||
export type PermissionPromptPosture =
|
||||
| "normal"
|
||||
| "batch_for_human"
|
||||
| "do_not_prompt_unless_blocked";
|
||||
|
||||
export const SAFE_PERMISSION_PROMPT_POSTURES = [
|
||||
"normal",
|
||||
"batch_for_human",
|
||||
"do_not_prompt_unless_blocked",
|
||||
] as const satisfies readonly PermissionPromptPosture[];
|
||||
|
||||
/**
|
||||
* Component 4 — citation source. Free-text in v0 (operator may cite
|
||||
* `current-mode.md`, a qitem id, a chatroom topic, or a convention-only
|
||||
* declaration). Structured citations are a Mode 1.5 amendment per
|
||||
* the convention.
|
||||
*/
|
||||
export type EvidenceCitation = string;
|
||||
|
||||
/**
|
||||
* Component 4 — expiry_or_stale_rule. v0 declares the field with a
|
||||
* conservative default ("re_confirm_on_long_gap"); the numeric
|
||||
* threshold is deferred per convention Q3 to a Mode 2 helper slice.
|
||||
* The rule values below enumerate the supported re-confirmation
|
||||
* triggers. NO silent-switch value exists — drift is always a
|
||||
* question, never an auto-mode-change.
|
||||
*/
|
||||
export type ExpiryOrStaleRule =
|
||||
| "none"
|
||||
| "re_confirm_on_long_gap"
|
||||
| "re_confirm_on_day_boundary"
|
||||
| "re_confirm_on_observed_conflict";
|
||||
|
||||
export const STALE_RULES = [
|
||||
"none",
|
||||
"re_confirm_on_long_gap",
|
||||
"re_confirm_on_day_boundary",
|
||||
"re_confirm_on_observed_conflict",
|
||||
] as const satisfies readonly ExpiryOrStaleRule[];
|
||||
|
||||
/**
|
||||
* Component 3 — the 10-field SETTINGS schema. ALL fields are required
|
||||
* by the validator; none may be merged or dropped. Reviewer field-set
|
||||
* integrity check (HG-2).
|
||||
*
|
||||
* **`mode` is NOT in this record.** Mode is the binding's identity
|
||||
* (Component 2 — the name of the named bundle); the record parameterizes
|
||||
* how that named mode shapes ergonomics. The binding wrapper
|
||||
* (OperatorContextModeBinding) carries `mode` at the top level so the
|
||||
* frozen 10-field settings record stays exactly 10 fields, as declared
|
||||
* by the convention's §Component 3 table.
|
||||
*/
|
||||
export interface OperatorContextModeRecord {
|
||||
autonomy_scope: AutonomyScope;
|
||||
heartbeat_cadence: HeartbeatCadence;
|
||||
inspection_depth: InspectionDepth;
|
||||
update_detail: UpdateDetail;
|
||||
escalation_threshold: EscalationThreshold;
|
||||
concurrency_limit: ConcurrencyLimit;
|
||||
permission_prompt_posture: PermissionPromptPosture;
|
||||
scope: OperatorContextScope;
|
||||
expiry_or_stale_rule: ExpiryOrStaleRule;
|
||||
evidence_citation: EvidenceCitation;
|
||||
}
|
||||
|
||||
/**
|
||||
* A scoped binding of a mode to a target context. The store keys rows
|
||||
* by (scope, qualifier) so the same scope can hold many bindings
|
||||
* (e.g., multiple rig-scoped modes), and the effective-mode resolver
|
||||
* picks the right one for the (rig, workstream, qitem) read context.
|
||||
*
|
||||
* - `global_host` bindings have a null qualifier.
|
||||
* - `rig` bindings carry the rig id as qualifier.
|
||||
* - `workstream` bindings carry the workstream id as qualifier.
|
||||
* - `qitem` bindings carry the qitem id as qualifier.
|
||||
*/
|
||||
export interface OperatorContextModeBinding {
|
||||
/** Stable identifier — `${scope}:${qualifier ?? "host"}` v0. */
|
||||
id: string;
|
||||
/** Component 2 — the named mode this binding selects. Lives at the
|
||||
* binding (not in the 10-field settings record) so the frozen
|
||||
* Component-3 record stays exactly 10 fields. */
|
||||
mode: OperatorContextMode;
|
||||
record: OperatorContextModeRecord;
|
||||
/** Qualifier value (rigId, workstreamId, qitemId) — null when scope is global_host. */
|
||||
qualifier: string | null;
|
||||
/** ISO timestamp of last set. Used by drift-rule consumers. */
|
||||
setAt: string;
|
||||
/** Who set it. Operator-only by contract — see update authority. */
|
||||
setBy: "operator";
|
||||
}
|
||||
|
||||
/**
|
||||
* The effective-mode read result. The resolver returns the most
|
||||
* specific binding for a (rig?, workstream?, qitem?) context, or
|
||||
* `null` when no binding exists at any matching scope.
|
||||
*
|
||||
* Per convention §"Q6 — Absent mode is unknown_posture, NOT desk":
|
||||
* callers MUST treat a null effective mode as `unknown_posture` and
|
||||
* re-confirm to a real mode explicitly. The resolver does NOT default
|
||||
* to `desk` and never invents a binding.
|
||||
*/
|
||||
export interface EffectiveOperatorContextMode {
|
||||
binding: OperatorContextModeBinding;
|
||||
/** Why this binding won (which scope; for debug + UI surfacing). */
|
||||
resolvedScope: OperatorContextScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read context for the resolver. All fields optional; the resolver
|
||||
* picks the most-specific applicable binding.
|
||||
*/
|
||||
export interface OperatorContextReadContext {
|
||||
rigId?: string;
|
||||
workstreamId?: string;
|
||||
qitemId?: string;
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Slice 09 — runtime validator for OperatorContextModeRecord input.
|
||||
//
|
||||
// The type system (rig-policy-types.ts) blocks invalid values at
|
||||
// compile time when callers go through the typed surface. This
|
||||
// validator is the defense for inputs that bypass types: JSON file
|
||||
// reads, HTTP request bodies, env-var-derived configuration, CLI
|
||||
// argument parsing, and migrations from older record shapes.
|
||||
//
|
||||
// HG-2: every record must carry all 10 fields. Reject missing/extra.
|
||||
// HG-1 / HG-SAFE / HG-8: closed enums; auto-accept rejected; no
|
||||
// silent-switch value.
|
||||
//
|
||||
// Error format follows the 3-part convention from the velocity team:
|
||||
// what failed / what's allowed / what to do
|
||||
// so an operator gets a useful CLI message.
|
||||
|
||||
import {
|
||||
type AutonomyScope,
|
||||
type ConcurrencyLimit,
|
||||
type EscalationThreshold,
|
||||
type ExpiryOrStaleRule,
|
||||
type HeartbeatCadence,
|
||||
type InspectionDepth,
|
||||
type OperatorContextMode,
|
||||
type OperatorContextModeRecord,
|
||||
type OperatorContextScope,
|
||||
type PermissionPromptPosture,
|
||||
type UpdateDetail,
|
||||
OPERATOR_CONTEXT_MODES,
|
||||
OPERATOR_CONTEXT_SCOPES,
|
||||
SAFE_PERMISSION_PROMPT_POSTURES,
|
||||
STALE_RULES,
|
||||
} from "./rig-policy-types.js";
|
||||
|
||||
const ALLOWED_AUTONOMY_SCOPES: readonly AutonomyScope[] = [
|
||||
"pre_approved_only",
|
||||
"bounded_continuation",
|
||||
"full_autonomy_within_workstream",
|
||||
"full_autonomy",
|
||||
];
|
||||
|
||||
const ALLOWED_HEARTBEAT_CADENCES: readonly HeartbeatCadence[] = ["sparse", "normal", "fast"];
|
||||
|
||||
const ALLOWED_INSPECTION_DEPTHS: readonly InspectionDepth[] = ["surface", "normal", "forensic"];
|
||||
|
||||
const ALLOWED_UPDATE_DETAILS: readonly UpdateDetail[] = ["compact", "normal", "verbose"];
|
||||
|
||||
const ALLOWED_ESCALATION_THRESHOLDS: readonly EscalationThreshold[] = [
|
||||
"low",
|
||||
"normal",
|
||||
"high",
|
||||
"blocker_only",
|
||||
];
|
||||
|
||||
const ALLOWED_CONCURRENCY_LIMITS: readonly ConcurrencyLimit[] = ["serial", "2", "4", "unlimited"];
|
||||
|
||||
/**
|
||||
* The 10 required fields of an OperatorContextModeRecord. Used by
|
||||
* validateRecord to enumerate field-set integrity (HG-2) — both
|
||||
* presence and exhaustiveness.
|
||||
*
|
||||
* NOTE: `mode` is NOT in this list. Mode is the binding selector
|
||||
* (Component 2 vocabulary), not part of the Component-3 settings
|
||||
* record. The store/route validate mode separately at the binding
|
||||
* boundary; this validator enforces only the 10 Component-3 fields.
|
||||
*/
|
||||
export const REQUIRED_RECORD_FIELDS: readonly (keyof OperatorContextModeRecord)[] = [
|
||||
"autonomy_scope",
|
||||
"heartbeat_cadence",
|
||||
"inspection_depth",
|
||||
"update_detail",
|
||||
"escalation_threshold",
|
||||
"concurrency_limit",
|
||||
"permission_prompt_posture",
|
||||
"scope",
|
||||
"expiry_or_stale_rule",
|
||||
"evidence_citation",
|
||||
];
|
||||
|
||||
export interface ValidationOk {
|
||||
ok: true;
|
||||
record: OperatorContextModeRecord;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
ok: false;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export type ValidationResult = ValidationOk | ValidationError;
|
||||
|
||||
function threePart(failed: string, allowed: string, recovery: string): string {
|
||||
return `${failed}. Allowed: ${allowed}. ${recovery}`;
|
||||
}
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function checkEnum<T extends string>(
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
errors: string[],
|
||||
): T | null {
|
||||
if (typeof value !== "string") {
|
||||
errors.push(
|
||||
threePart(
|
||||
`${fieldName} is not a string (got ${typeof value})`,
|
||||
allowed.join(", "),
|
||||
`Set ${fieldName} to one of the allowed values.`,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!(allowed as readonly string[]).includes(value)) {
|
||||
errors.push(
|
||||
threePart(
|
||||
`${fieldName}="${value}" is not a recognized value`,
|
||||
allowed.join(", "),
|
||||
`Set ${fieldName} to one of the allowed values.`,
|
||||
),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a candidate record against the FROZEN contract. Returns
|
||||
* the typed record on success OR a list of 3-part error messages on
|
||||
* failure (one per offending field; report all at once so an
|
||||
* operator doesn't have to retry per error).
|
||||
*
|
||||
* Strict: any field outside REQUIRED_RECORD_FIELDS is rejected
|
||||
* (HG-2 — field-set integrity; "no merged/dropped fields").
|
||||
*/
|
||||
export function validateRecord(raw: unknown): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isPlainObject(raw)) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
threePart(
|
||||
"Record is not an object",
|
||||
"an object with the 10 required fields",
|
||||
"See conventions/operator-context-mode-system/README.md §Component 3 for the schema.",
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// HG-2 — field-set integrity. Reject missing AND extra fields. The
|
||||
// convention says "All ten fields MUST be present in any descendant
|
||||
// that publishes a current-mode artifact. None may be silently
|
||||
// merged or dropped."
|
||||
for (const field of REQUIRED_RECORD_FIELDS) {
|
||||
if (!(field in raw)) {
|
||||
errors.push(
|
||||
threePart(
|
||||
`Missing required field "${field}"`,
|
||||
REQUIRED_RECORD_FIELDS.join(", "),
|
||||
`Add the field to the record. See convention §Component 3.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const requiredSet = new Set<string>(REQUIRED_RECORD_FIELDS);
|
||||
for (const key of Object.keys(raw)) {
|
||||
if (!requiredSet.has(key)) {
|
||||
errors.push(
|
||||
threePart(
|
||||
`Unknown field "${key}"`,
|
||||
REQUIRED_RECORD_FIELDS.join(", "),
|
||||
`Drop the unknown field. The schema is closed at v0; an extension requires a Mode 1.5 amendment.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// If we already failed field-set integrity, return early so the
|
||||
// operator sees only the structural error and not cascade per-field
|
||||
// noise.
|
||||
if (errors.length > 0) return { ok: false, errors };
|
||||
|
||||
const autonomy_scope = checkEnum<AutonomyScope>(
|
||||
"autonomy_scope",
|
||||
raw["autonomy_scope"],
|
||||
ALLOWED_AUTONOMY_SCOPES,
|
||||
errors,
|
||||
);
|
||||
const heartbeat_cadence = checkEnum<HeartbeatCadence>(
|
||||
"heartbeat_cadence",
|
||||
raw["heartbeat_cadence"],
|
||||
ALLOWED_HEARTBEAT_CADENCES,
|
||||
errors,
|
||||
);
|
||||
const inspection_depth = checkEnum<InspectionDepth>(
|
||||
"inspection_depth",
|
||||
raw["inspection_depth"],
|
||||
ALLOWED_INSPECTION_DEPTHS,
|
||||
errors,
|
||||
);
|
||||
const update_detail = checkEnum<UpdateDetail>(
|
||||
"update_detail",
|
||||
raw["update_detail"],
|
||||
ALLOWED_UPDATE_DETAILS,
|
||||
errors,
|
||||
);
|
||||
const escalation_threshold = checkEnum<EscalationThreshold>(
|
||||
"escalation_threshold",
|
||||
raw["escalation_threshold"],
|
||||
ALLOWED_ESCALATION_THRESHOLDS,
|
||||
errors,
|
||||
);
|
||||
const concurrency_limit = checkEnum<ConcurrencyLimit>(
|
||||
"concurrency_limit",
|
||||
raw["concurrency_limit"],
|
||||
ALLOWED_CONCURRENCY_LIMITS,
|
||||
errors,
|
||||
);
|
||||
|
||||
// HG-SAFE (runtime) — the type system blocks auto-accept at
|
||||
// compile time; this branch rejects it at runtime for inputs that
|
||||
// bypass typing (JSON / env / HTTP body). The validator does NOT
|
||||
// tolerate any "auto*" / "yes_to_all" / etc. value; it accepts
|
||||
// EXACTLY the three SAFE values.
|
||||
const permission_prompt_posture = checkEnum<PermissionPromptPosture>(
|
||||
"permission_prompt_posture",
|
||||
raw["permission_prompt_posture"],
|
||||
SAFE_PERMISSION_PROMPT_POSTURES,
|
||||
errors,
|
||||
);
|
||||
|
||||
const scope = checkEnum<OperatorContextScope>(
|
||||
"scope",
|
||||
raw["scope"],
|
||||
OPERATOR_CONTEXT_SCOPES,
|
||||
errors,
|
||||
);
|
||||
|
||||
const expiry_or_stale_rule = checkEnum<ExpiryOrStaleRule>(
|
||||
"expiry_or_stale_rule",
|
||||
raw["expiry_or_stale_rule"],
|
||||
STALE_RULES,
|
||||
errors,
|
||||
);
|
||||
|
||||
const evidence_citation_raw = raw["evidence_citation"];
|
||||
if (typeof evidence_citation_raw !== "string") {
|
||||
errors.push(
|
||||
threePart(
|
||||
`evidence_citation is not a string (got ${typeof evidence_citation_raw})`,
|
||||
"a non-empty source-citation string (e.g., qitem id, file path, chatroom topic)",
|
||||
"Provide a brief citation per convention §Citation Rules.",
|
||||
),
|
||||
);
|
||||
} else if (evidence_citation_raw.trim().length === 0) {
|
||||
errors.push(
|
||||
threePart(
|
||||
"evidence_citation is empty",
|
||||
"a non-empty source-citation string",
|
||||
"Provide a brief citation per convention §Citation Rules.",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
errors.length > 0
|
||||
|| autonomy_scope === null
|
||||
|| heartbeat_cadence === null
|
||||
|| inspection_depth === null
|
||||
|| update_detail === null
|
||||
|| escalation_threshold === null
|
||||
|| concurrency_limit === null
|
||||
|| permission_prompt_posture === null
|
||||
|| scope === null
|
||||
|| expiry_or_stale_rule === null
|
||||
) {
|
||||
return { ok: false, errors };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
record: {
|
||||
autonomy_scope,
|
||||
heartbeat_cadence,
|
||||
inspection_depth,
|
||||
update_detail,
|
||||
escalation_threshold,
|
||||
concurrency_limit,
|
||||
permission_prompt_posture,
|
||||
scope,
|
||||
expiry_or_stale_rule,
|
||||
evidence_citation: (evidence_citation_raw as string).trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an operator-supplied mode name. Mode lives outside the
|
||||
* 10-field record (Component 2 vocabulary, not Component 3 settings).
|
||||
* The route + store call this at the binding boundary; the
|
||||
* disambiguator below (for invocation parsing) is separate.
|
||||
*/
|
||||
export function validateModeName(raw: unknown): { ok: true; mode: OperatorContextMode } | { ok: false; error: string } {
|
||||
if (typeof raw !== "string") {
|
||||
return { ok: false, error: threePart(`mode is not a string (got ${typeof raw})`, OPERATOR_CONTEXT_MODES.join(", "), "Provide one of the six reserved mode names.") };
|
||||
}
|
||||
if (!(OPERATOR_CONTEXT_MODES as readonly string[]).includes(raw)) {
|
||||
return { ok: false, error: threePart(`mode="${raw}" is not a recognized mode name`, OPERATOR_CONTEXT_MODES.join(", "), "Provide one of the six reserved mode names. See convention §Component 2.") };
|
||||
}
|
||||
return { ok: true, mode: raw as OperatorContextMode };
|
||||
}
|
||||
|
||||
/**
|
||||
* Disambiguate operator-supplied mode input. Per convention §Component 4
|
||||
* "Bare-Word Disambiguation":
|
||||
*
|
||||
* - A bare word that is one of the six reserved modes → invocation
|
||||
* - An explicit `mode:` prefix → invocation
|
||||
* - A word embedded in a sentence → NOT invocation (caller treats as topic)
|
||||
*
|
||||
* Returns the canonical mode name when the input is unambiguously an
|
||||
* invocation; null otherwise. Caller emits a clarification question
|
||||
* for null+bare-multi-word inputs (per convention "ask once").
|
||||
*/
|
||||
export function disambiguateModeInvocation(rawInput: string): OperatorContextMode | null {
|
||||
const trimmed = rawInput.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
|
||||
// Explicit prefix wins.
|
||||
const prefixMatch = trimmed.match(/^mode\s*:\s*(\S+)/i);
|
||||
if (prefixMatch) {
|
||||
const candidate = prefixMatch[1]!.toLowerCase();
|
||||
if ((OPERATOR_CONTEXT_MODES as readonly string[]).includes(candidate)) {
|
||||
return candidate as OperatorContextMode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bare-word: exactly one word AND it's a reserved mode.
|
||||
if (/^\S+$/.test(trimmed)) {
|
||||
const lower = trimmed.toLowerCase();
|
||||
if ((OPERATOR_CONTEXT_MODES as readonly string[]).includes(lower)) {
|
||||
return lower as OperatorContextMode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Embedded — let the caller ask.
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Slice 09 — Rig Policy (operator context mode) HTTP routes.
|
||||
//
|
||||
// Surface (binding-related ONLY — HG-SAFE):
|
||||
// GET /api/rig-policy/bindings — list bindings
|
||||
// GET /api/rig-policy/bindings/:scope/:qualifier? — read one binding
|
||||
// PUT /api/rig-policy/bindings/:scope/:qualifier? — upsert (operator)
|
||||
// DELETE /api/rig-policy/bindings/:scope/:qualifier? — unset (operator)
|
||||
// GET /api/rig-policy/effective — resolve effective
|
||||
// (?rig=&workstream=&qitem=)
|
||||
// GET /api/rig-policy/defaults — recommended
|
||||
// per-mode 6×7
|
||||
// + default scope
|
||||
// + DEFAULT_STALE_RULE
|
||||
//
|
||||
// Authority (HG-4): write verbs (PUT / DELETE) require the daemon's
|
||||
// operator bearer (same posture as mission-control). Reads are open
|
||||
// (within the daemon's existing loopback/tailnet/bearer model). There
|
||||
// is NO agent-set code path — the route mounts the bearer middleware
|
||||
// on write verbs only; agents calling read endpoints must use the same
|
||||
// auth the daemon already enforces at the listen layer.
|
||||
//
|
||||
// HG-SAFE preserved: this router NEVER touches permission allowlists /
|
||||
// runtime configs / auth tokens / tmux / lifecycle. It calls a single
|
||||
// store (RigPolicyStore) whose surface area is itself binding-limited.
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { authBearerTokenMiddleware } from "../middleware/auth-bearer-token.js";
|
||||
import type { RigPolicyStore } from "../domain/rig-policy/rig-policy-store.js";
|
||||
import {
|
||||
OPERATOR_CONTEXT_SCOPES,
|
||||
type OperatorContextScope,
|
||||
} from "../domain/rig-policy/rig-policy-types.js";
|
||||
import {
|
||||
DEFAULT_STALE_RULE,
|
||||
RECOMMENDED_DEFAULT_SCOPE,
|
||||
RECOMMENDED_MODE_DEFAULTS,
|
||||
} from "../domain/rig-policy/rig-policy-defaults.js";
|
||||
|
||||
export interface RigPolicyRoutesOpts {
|
||||
/** Operator bearer token (same one Mission Control uses). When null,
|
||||
* the daemon's listen layer is loopback-only and write verbs pass
|
||||
* through; non-null requires `Authorization: Bearer <token>`. */
|
||||
bearerToken?: string | null;
|
||||
}
|
||||
|
||||
const VALID_SCOPES = new Set<string>(OPERATOR_CONTEXT_SCOPES);
|
||||
|
||||
function parseScope(raw: string): OperatorContextScope | null {
|
||||
return VALID_SCOPES.has(raw) ? (raw as OperatorContextScope) : null;
|
||||
}
|
||||
|
||||
interface ScopeQualifierOk {
|
||||
ok: true;
|
||||
scope: OperatorContextScope;
|
||||
qualifier: string | null;
|
||||
}
|
||||
interface ScopeQualifierErr {
|
||||
ok: false;
|
||||
status: 400;
|
||||
body: { error: string; hint: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate `:scope` / `:qualifier` path params. Rejects:
|
||||
* - unknown scope name → scope_invalid
|
||||
* - global_host scope WITH a non-empty qualifier → qualifier_forbidden
|
||||
* (prevents hidden scope inference / mutation on the global-host
|
||||
* binding — guard BLOCKER 2)
|
||||
* - non-global scope WITHOUT a qualifier → qualifier_required
|
||||
*
|
||||
* Used by every route verb so GET / PUT / DELETE share the exact same
|
||||
* parse rules. No silent qualifier-dropping anywhere on a write path.
|
||||
*/
|
||||
function parseScopeAndQualifier(scopeRaw: string, qualifierRaw: string | undefined): ScopeQualifierOk | ScopeQualifierErr {
|
||||
const scope = parseScope(scopeRaw);
|
||||
if (!scope) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
body: { error: "scope_invalid", hint: `Unknown scope. Allowed: ${OPERATOR_CONTEXT_SCOPES.join(", ")}.` },
|
||||
};
|
||||
}
|
||||
if (scope === "global_host") {
|
||||
if (qualifierRaw !== undefined && qualifierRaw.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
body: {
|
||||
error: "qualifier_forbidden",
|
||||
hint: "Global-host bindings cannot carry a qualifier. Use /api/rig-policy/bindings/global_host with no trailing path segment.",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, scope, qualifier: null };
|
||||
}
|
||||
if (qualifierRaw === undefined || qualifierRaw.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
body: {
|
||||
error: "qualifier_required",
|
||||
hint: `Scope ${scope} requires a qualifier (rigId / workstreamId / qitemId).`,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true, scope, qualifier: qualifierRaw };
|
||||
}
|
||||
|
||||
function getStore(c: { get: (key: string) => unknown }): RigPolicyStore | null {
|
||||
const store = c.get("rigPolicyStore" as never) as RigPolicyStore | undefined;
|
||||
return store ?? null;
|
||||
}
|
||||
|
||||
export function rigPolicyRoutes(opts?: RigPolicyRoutesOpts): Hono {
|
||||
const router = new Hono();
|
||||
const bearer = opts?.bearerToken ?? null;
|
||||
const requireOperator = authBearerTokenMiddleware({ expectedToken: bearer });
|
||||
|
||||
// -- read: defaults --------------------------------------------------
|
||||
router.get("/defaults", (c) => {
|
||||
return c.json({
|
||||
recommendedModeDefaults: RECOMMENDED_MODE_DEFAULTS,
|
||||
recommendedDefaultScope: RECOMMENDED_DEFAULT_SCOPE,
|
||||
defaultStaleRule: DEFAULT_STALE_RULE,
|
||||
});
|
||||
});
|
||||
|
||||
// -- read: list ------------------------------------------------------
|
||||
router.get("/bindings", (c) => {
|
||||
const store = getStore(c);
|
||||
if (!store) return c.json({ error: "rig_policy_store_unavailable" }, 503);
|
||||
return c.json({ bindings: store.listBindings() });
|
||||
});
|
||||
|
||||
// -- read: resolveEffective ------------------------------------------
|
||||
router.get("/effective", (c) => {
|
||||
const store = getStore(c);
|
||||
if (!store) return c.json({ error: "rig_policy_store_unavailable" }, 503);
|
||||
const rigId = c.req.query("rig") || undefined;
|
||||
const workstreamId = c.req.query("workstream") || undefined;
|
||||
const qitemId = c.req.query("qitem") || undefined;
|
||||
const resolved = store.resolveEffective({ rigId, workstreamId, qitemId });
|
||||
// Q6 — null = unknown_posture; do NOT silently default. Surface to caller.
|
||||
if (!resolved) {
|
||||
return c.json({
|
||||
effective: null,
|
||||
posture: "unknown_posture",
|
||||
hint: "No binding matches this read context. Per convention §Q6 callers MUST treat this as unknown_posture (do not default to desk).",
|
||||
});
|
||||
}
|
||||
return c.json({ effective: resolved, posture: "known" });
|
||||
});
|
||||
|
||||
// -- read: one binding (qualifier path-optional via /:scope or /:scope/:qualifier)
|
||||
router.get("/bindings/:scope/:qualifier?", (c) => {
|
||||
const store = getStore(c);
|
||||
if (!store) return c.json({ error: "rig_policy_store_unavailable" }, 503);
|
||||
const parsed = parseScopeAndQualifier(c.req.param("scope"), c.req.param("qualifier"));
|
||||
if (!parsed.ok) return c.json(parsed.body, parsed.status);
|
||||
const binding = store.getBinding(parsed.scope, parsed.qualifier);
|
||||
if (!binding) return c.json({ error: "not_found" }, 404);
|
||||
return c.json({ binding });
|
||||
});
|
||||
|
||||
// -- write: upsert (operator-only) -----------------------------------
|
||||
router.put("/bindings/:scope/:qualifier?", requireOperator, async (c) => {
|
||||
const store = getStore(c);
|
||||
if (!store) return c.json({ error: "rig_policy_store_unavailable" }, 503);
|
||||
const parsed = parseScopeAndQualifier(c.req.param("scope"), c.req.param("qualifier"));
|
||||
if (!parsed.ok) return c.json(parsed.body, parsed.status);
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (body === null || typeof body !== "object") {
|
||||
return c.json({
|
||||
error: "body_required",
|
||||
hint: "PUT body must be { mode: <one of six>, record: <10-field OperatorContextModeRecord> }.",
|
||||
}, 400);
|
||||
}
|
||||
const { mode, record } = body as { mode?: unknown; record?: unknown };
|
||||
if (mode === undefined || record === undefined) {
|
||||
return c.json({
|
||||
error: "body_shape_invalid",
|
||||
hint: "PUT body must be { mode: <one of six>, record: <10-field OperatorContextModeRecord> }.",
|
||||
}, 400);
|
||||
}
|
||||
const result = store.setBinding(parsed.scope, parsed.qualifier, mode, record);
|
||||
if (!result.ok) {
|
||||
return c.json({ error: "validation_failed", errors: result.errors }, 400);
|
||||
}
|
||||
return c.json({ binding: result.binding });
|
||||
});
|
||||
|
||||
// -- write: delete (operator-only) -----------------------------------
|
||||
router.delete("/bindings/:scope/:qualifier?", requireOperator, (c) => {
|
||||
const store = getStore(c);
|
||||
if (!store) return c.json({ error: "rig_policy_store_unavailable" }, 503);
|
||||
const parsed = parseScopeAndQualifier(c.req.param("scope"), c.req.param("qualifier"));
|
||||
if (!parsed.ok) return c.json(parsed.body, parsed.status);
|
||||
const removed = store.deleteBinding(parsed.scope, parsed.qualifier);
|
||||
return c.json({ removed });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -81,6 +81,7 @@ import { watchdogRoutes } from "./routes/watchdog.js";
|
||||
import { workflowRoutes } from "./routes/workflow.js";
|
||||
import { missionControlRoutes } from "./routes/mission-control.js";
|
||||
import { slicesRoutes } from "./routes/slices.js";
|
||||
import { rigPolicyRoutes } from "./routes/rig-policy.js";
|
||||
import { missionsRoutes } from "./routes/missions.js";
|
||||
import { rigCmuxRoutes } from "./routes/rig-cmux.js";
|
||||
import { CmuxLayoutService } from "./domain/cmux-layout-service.js";
|
||||
@@ -253,6 +254,9 @@ export interface AppDeps {
|
||||
* booting the kernel; the route returns 503 with a clear message
|
||||
* when the tracker isn't wired. */
|
||||
kernelBootTracker?: import("./domain/kernel-boot-tracker.js").KernelBootTracker;
|
||||
/** Slice 09 (OPR.0.3.2.9) — operator-context-mode bindings store.
|
||||
* Optional: when absent, the rig-policy routes return 503. */
|
||||
rigPolicyStore?: import("./domain/rig-policy/rig-policy-store.js").RigPolicyStore;
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
@@ -450,6 +454,7 @@ export function createApp(deps: AppDeps): Hono {
|
||||
c.set("serviceOrchestrator" as never, deps.serviceOrchestrator);
|
||||
c.set("composeAdapter" as never, deps.composeAdapter);
|
||||
c.set("kernelBootTracker" as never, deps.kernelBootTracker);
|
||||
c.set("rigPolicyStore" as never, deps.rigPolicyStore);
|
||||
c.set("db" as never, deps.rigRepo.db);
|
||||
await next();
|
||||
});
|
||||
@@ -522,6 +527,12 @@ export function createApp(deps: AppDeps): Hono {
|
||||
app.route("/api/health-summary", healthSummaryRoutes());
|
||||
app.route("/api/rigs/:rigId/env", envRoutes());
|
||||
app.route("/api/restore-check", restoreCheckRoutes);
|
||||
// Slice 09 (OPR.0.3.2.9) — rig-policy bindings (operator context mode).
|
||||
// Same operator-bearer posture as mission-control; HG-SAFE preserved.
|
||||
app.route(
|
||||
"/api/rig-policy",
|
||||
rigPolicyRoutes({ bearerToken: deps.missionControlBearerToken ?? null }),
|
||||
);
|
||||
|
||||
const uiDistDir = deps.uiDistDir ?? resolveDefaultUiDistDir();
|
||||
const uiIndexPath = nodePath.join(uiDistDir, "index.html");
|
||||
|
||||
@@ -131,6 +131,8 @@ import { queueTargetRepoSchema } from "./db/migrations/039_queue_target_repo.js"
|
||||
// status + error_message columns to workflow_specs so the scanner
|
||||
// can record diagnostic rows. SC-29 #10 declared verbatim in commit body.
|
||||
import { workflowSpecsDiagnosticSchema } from "./db/migrations/040_workflow_specs_diagnostic.js";
|
||||
import { rigPolicySchema } from "./db/migrations/041_rig_policy.js";
|
||||
import { RigPolicyStore } from "./domain/rig-policy/rig-policy-store.js";
|
||||
import { MissionControlActionLog } from "./domain/mission-control/mission-control-action-log.js";
|
||||
import { MissionControlWriteContract } from "./domain/mission-control/mission-control-write-contract.js";
|
||||
import { MissionControlReadLayer } from "./domain/mission-control/mission-control-read-layer.js";
|
||||
@@ -204,7 +206,7 @@ export function collectAllowlistedProviderAuthEnv(
|
||||
export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult> {
|
||||
const dbPath = opts?.dbPath ?? ":memory:";
|
||||
const db = createDb(dbPath);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema, workspacePrimitiveSchema, queueTargetRepoSchema, workflowSpecsDiagnosticSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, inboxEntriesSchema, outboxEntriesSchema, projectClassificationsSchema, classifierLeasesSchema, viewsCustomSchema, watchdogJobsSchema, watchdogHistorySchema, workflowSpecsSchema, workflowInstancesSchema, workflowStepTrailsSchema, watchdogPolicyEnumExtensionSchema, missionControlActionsSchema, workspacePrimitiveSchema, queueTargetRepoSchema, workflowSpecsDiagnosticSchema, rigPolicySchema]);
|
||||
|
||||
const rigRepo = new RigRepository(db);
|
||||
const sessionRegistry = new SessionRegistry(db);
|
||||
@@ -826,6 +828,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// PodRigInstantiator's session_source: mode: agent_image dispatch.
|
||||
agentImageLibrary,
|
||||
snapshotCapturer,
|
||||
// Slice 09 (OPR.0.3.2.9) — operator-context-mode bindings store
|
||||
// (typed primitive on the shared db handle; HG-5: no parallel store).
|
||||
rigPolicyStore: new RigPolicyStore(db),
|
||||
agentImageSpecRoots: () => {
|
||||
// Spec-library roots scanned by the evidence guard. v0: user
|
||||
// specs under ~/.openrig/specs + workspace-local specs root
|
||||
|
||||
@@ -31,6 +31,7 @@ import { nodeCodexConfigProfileSchema } from "../../src/db/migrations/022_node_c
|
||||
import { streamItemsSchema } from "../../src/db/migrations/023_stream_items.js";
|
||||
import { queueItemsSchema } from "../../src/db/migrations/024_queue_items.js";
|
||||
import { queueTransitionsSchema } from "../../src/db/migrations/025_queue_transitions.js";
|
||||
import { rigPolicySchema } from "../../src/db/migrations/041_rig_policy.js";
|
||||
import { BootstrapRepository } from "../../src/domain/bootstrap-repository.js";
|
||||
import { RuntimeVerifier } from "../../src/domain/runtime-verifier.js";
|
||||
import { RequirementsProbeRegistry } from "../../src/domain/requirements-probe.js";
|
||||
@@ -85,7 +86,7 @@ import fs from "node:fs";
|
||||
|
||||
export function createFullTestDb(): Database.Database {
|
||||
const db = createDb();
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema]);
|
||||
migrate(db, [coreSchema, bindingsSessionsSchema, eventsSchema, snapshotsSchema, checkpointsSchema, resumeMetadataSchema, nodeSpecFieldsSchema, packagesSchema, installJournalSchema, journalSeqSchema, bootstrapSchema, discoverySchema, discoveryFkFix, agentspecRebootSchema, startupContextSchema, chatMessagesSchema, podNamespaceSchema, contextUsageSchema, externalCliAttachmentSchema, rigServicesSchema, seatHandoverObservabilitySchema, nodeCodexConfigProfileSchema, streamItemsSchema, queueItemsSchema, queueTransitionsSchema, rigPolicySchema]);
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// Slice 09 — recommended defaults (Component 3 6×7 + Component 4 scope).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DEFAULT_STALE_RULE,
|
||||
RECOMMENDED_DEFAULT_SCOPE,
|
||||
RECOMMENDED_MODE_DEFAULTS,
|
||||
} from "../src/domain/rig-policy/rig-policy-defaults.js";
|
||||
import {
|
||||
OPERATOR_CONTEXT_MODES,
|
||||
SAFE_PERMISSION_PROMPT_POSTURES,
|
||||
} from "../src/domain/rig-policy/rig-policy-types.js";
|
||||
import { validateRecord } from "../src/domain/rig-policy/rig-policy-validator.js";
|
||||
|
||||
describe("rig-policy defaults — slice 09 §Component 3 + §Component 4", () => {
|
||||
// HG-6 — every mode has a default row in the 6×7 matrix.
|
||||
it("HG-6: every mode has a recommended-defaults row", () => {
|
||||
for (const mode of OPERATOR_CONTEXT_MODES) {
|
||||
expect(RECOMMENDED_MODE_DEFAULTS[mode]).toBeDefined();
|
||||
}
|
||||
expect(Object.keys(RECOMMENDED_MODE_DEFAULTS).sort()).toEqual([...OPERATOR_CONTEXT_MODES].sort());
|
||||
});
|
||||
|
||||
// HG-6 — every mode has a default scope.
|
||||
it("HG-6: every mode has a recommended-default scope", () => {
|
||||
for (const mode of OPERATOR_CONTEXT_MODES) {
|
||||
expect(RECOMMENDED_DEFAULT_SCOPE[mode]).toBeDefined();
|
||||
}
|
||||
expect(Object.keys(RECOMMENDED_DEFAULT_SCOPE).sort()).toEqual([...OPERATOR_CONTEXT_MODES].sort());
|
||||
});
|
||||
|
||||
// HG-SAFE — every default permission_prompt_posture is one of the
|
||||
// three SAFE values. Defense even at the defaults layer: it should
|
||||
// be impossible to ship a default that violates the no-auto-accept
|
||||
// rule.
|
||||
it("HG-SAFE: every per-mode default permission_prompt_posture is in SAFE_PERMISSION_PROMPT_POSTURES", () => {
|
||||
for (const mode of OPERATOR_CONTEXT_MODES) {
|
||||
const def = RECOMMENDED_MODE_DEFAULTS[mode];
|
||||
expect(SAFE_PERMISSION_PROMPT_POSTURES).toContain(def.permission_prompt_posture);
|
||||
}
|
||||
});
|
||||
|
||||
// Spot-check the convention's specific table values to catch silent
|
||||
// drift between the source-of-truth doc and the ship'd defaults.
|
||||
it("matches the convention §Component 3 table for sleep / debug / mobile (load-bearing rows)", () => {
|
||||
expect(RECOMMENDED_MODE_DEFAULTS.sleep).toMatchObject({
|
||||
autonomy_scope: "pre_approved_only",
|
||||
heartbeat_cadence: "sparse",
|
||||
escalation_threshold: "blocker_only",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
});
|
||||
expect(RECOMMENDED_MODE_DEFAULTS.debug).toMatchObject({
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "fast",
|
||||
inspection_depth: "forensic",
|
||||
update_detail: "verbose",
|
||||
concurrency_limit: "serial",
|
||||
// Per convention Component 6 + applied proof: debug does NOT
|
||||
// widen permission defaults. Posture stays `normal`.
|
||||
permission_prompt_posture: "normal",
|
||||
});
|
||||
expect(RECOMMENDED_MODE_DEFAULTS.mobile).toMatchObject({
|
||||
autonomy_scope: "bounded_continuation",
|
||||
inspection_depth: "surface",
|
||||
escalation_threshold: "low",
|
||||
permission_prompt_posture: "batch_for_human",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches the convention §Component 4 default-scope table", () => {
|
||||
expect(RECOMMENDED_DEFAULT_SCOPE).toEqual({
|
||||
sleep: "global_host",
|
||||
away: "global_host",
|
||||
desk: "global_host",
|
||||
mobile: "global_host",
|
||||
focus: "workstream",
|
||||
debug: "qitem",
|
||||
});
|
||||
});
|
||||
|
||||
// Per convention Q3, the numeric threshold is deferred; the rule
|
||||
// KIND `re_confirm_on_long_gap` is the conservative default
|
||||
// selected at v0.
|
||||
it("DEFAULT_STALE_RULE is the convention's conservative re-confirmation rule kind", () => {
|
||||
expect(DEFAULT_STALE_RULE).toBe("re_confirm_on_long_gap");
|
||||
});
|
||||
|
||||
// End-to-end: a complete record assembled from defaults + the
|
||||
// default scope + the default stale rule passes the validator.
|
||||
// This is the executable proof that the defaults compose into a
|
||||
// valid v0 record (no silent dropped/extra fields).
|
||||
it("HG-6 executable proof: defaults + default scope + DEFAULT_STALE_RULE compose into a validator-valid record for every mode (10-field record; mode lives at the binding, NOT in the record)", () => {
|
||||
for (const mode of OPERATOR_CONTEXT_MODES) {
|
||||
const defaults = RECOMMENDED_MODE_DEFAULTS[mode];
|
||||
const scope = RECOMMENDED_DEFAULT_SCOPE[mode];
|
||||
const record = {
|
||||
...defaults,
|
||||
scope,
|
||||
expiry_or_stale_rule: DEFAULT_STALE_RULE,
|
||||
evidence_citation: `operator confirmed ${mode}`,
|
||||
};
|
||||
// The record itself must be exactly 10 fields (Component-3 frozen
|
||||
// contract). Verify count to anchor field-set integrity here too.
|
||||
expect(Object.keys(record).length).toBe(10);
|
||||
const result = validateRecord(record);
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Slice 09 — HG-8 drift-rule discipline.
|
||||
//
|
||||
// The convention's drift-recovery rule is: long-gap / day-boundary /
|
||||
// observed-conflict → re-confirm (a QUESTION, never a silent switch).
|
||||
// No signal→auto-mode path exists. v0 ships the
|
||||
// `expiry_or_stale_rule` FIELD + a conservative default; downstream
|
||||
// consumers READ it and prompt re-confirmation. The daemon NEVER
|
||||
// auto-switches a binding.
|
||||
//
|
||||
// This file anchors that discipline at the slice source level:
|
||||
//
|
||||
// 1. Conservative default rule-kind is `re_confirm_on_long_gap`.
|
||||
// 2. Validator rejects auto-switch-shaped rule values that aren't
|
||||
// in the closed enum.
|
||||
// 3. The store NEVER mutates bindings during a read. resolveEffective
|
||||
// is pure — same binding, same setAt, repeatable.
|
||||
// 4. Source grep: rig-policy domain code contains NO identifiers
|
||||
// that imply automatic mode-switching (auto-switch / auto-apply /
|
||||
// signal-based / etc.).
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigPolicyStore } from "../src/domain/rig-policy/rig-policy-store.js";
|
||||
import { DEFAULT_STALE_RULE } from "../src/domain/rig-policy/rig-policy-defaults.js";
|
||||
import { STALE_RULES, type OperatorContextModeRecord } from "../src/domain/rig-policy/rig-policy-types.js";
|
||||
import { validateRecord } from "../src/domain/rig-policy/rig-policy-validator.js";
|
||||
|
||||
function makeRecord(overrides?: Partial<OperatorContextModeRecord>): OperatorContextModeRecord {
|
||||
return {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "fast",
|
||||
inspection_depth: "forensic",
|
||||
update_detail: "verbose",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "normal",
|
||||
scope: "qitem",
|
||||
expiry_or_stale_rule: "re_confirm_on_long_gap",
|
||||
evidence_citation: "operator confirmed debug",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HG-8 drift-rule mechanism — convention §Component 4 + §Q3", () => {
|
||||
it("DEFAULT_STALE_RULE is the convention's conservative re-confirmation rule", () => {
|
||||
expect(DEFAULT_STALE_RULE).toBe("re_confirm_on_long_gap");
|
||||
expect(STALE_RULES).toContain(DEFAULT_STALE_RULE);
|
||||
});
|
||||
|
||||
it("validator rejects auto-switch-shaped rule values not in the closed enum", () => {
|
||||
for (const auto of ["auto_switch", "auto_apply", "switch_on_long_gap", "silent_switch", "on_signal"]) {
|
||||
const res = validateRecord(makeRecord({ expiry_or_stale_rule: auto as unknown as OperatorContextModeRecord["expiry_or_stale_rule"] }));
|
||||
expect(res.ok, `value '${auto}' must be rejected`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("validator accepts every member of STALE_RULES", () => {
|
||||
for (const rule of STALE_RULES) {
|
||||
const res = validateRecord(makeRecord({ expiry_or_stale_rule: rule }));
|
||||
expect(res.ok, `value '${rule}' must be accepted`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HG-8 — store reads NEVER mutate bindings (no silent switch path)", () => {
|
||||
let db: Database.Database;
|
||||
let store: RigPolicyStore;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
store = new RigPolicyStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("resolveEffective is pure: repeated reads return the same binding (same setAt)", () => {
|
||||
store.setBinding("qitem", "q-1", "debug", makeRecord({ scope: "qitem" }));
|
||||
const r1 = store.resolveEffective({ qitemId: "q-1" });
|
||||
const r2 = store.resolveEffective({ qitemId: "q-1" });
|
||||
const r3 = store.resolveEffective({ qitemId: "q-1" });
|
||||
expect(r1).not.toBeNull();
|
||||
expect(r2!.binding.setAt).toBe(r1!.binding.setAt);
|
||||
expect(r3!.binding.setAt).toBe(r1!.binding.setAt);
|
||||
expect(r1!.binding.mode).toBe("debug");
|
||||
expect(r2!.binding.mode).toBe("debug");
|
||||
});
|
||||
|
||||
it("getBinding is pure: repeated reads return the same record", () => {
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig" }));
|
||||
const first = store.getBinding("rig", "rig-a");
|
||||
const second = store.getBinding("rig", "rig-a");
|
||||
expect(first!.setAt).toBe(second!.setAt);
|
||||
expect(first!.record).toEqual(second!.record);
|
||||
});
|
||||
|
||||
it("listBindings is pure: count and identities stable across reads", () => {
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig" }));
|
||||
const a = store.listBindings();
|
||||
const b = store.listBindings();
|
||||
expect(a.map((x) => x.id).sort()).toEqual(b.map((x) => x.id).sort());
|
||||
expect(a.map((x) => x.setAt)).toEqual(b.map((x) => x.setAt));
|
||||
});
|
||||
|
||||
// Negative — auto-switch class. No code path in the slice domain
|
||||
// module mutates a binding's mode from a signal / timer / external
|
||||
// observation. This is the source-level discriminator for
|
||||
// "no silent switch path exists" (HG-8).
|
||||
it("HG-8 source grep: rig-policy domain code contains no auto-switch / auto-apply / signal-driven identifiers", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const url = await import("node:url");
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const domainDir = path.join(here, "..", "src", "domain", "rig-policy");
|
||||
const sources = ["rig-policy-types.ts", "rig-policy-validator.ts", "rig-policy-defaults.ts", "rig-policy-store.ts"];
|
||||
const combined = sources.map((f) => fs.readFileSync(path.join(domainDir, f), "utf-8")).join("\n");
|
||||
for (const forbidden of [
|
||||
"autoSwitch",
|
||||
"auto_switch",
|
||||
"autoApply",
|
||||
"auto_apply",
|
||||
"silentSwitch",
|
||||
"silent_switch",
|
||||
"onSignal",
|
||||
"on_signal",
|
||||
"fromSignal",
|
||||
"from_signal",
|
||||
"autoSetMode",
|
||||
"auto_set_mode",
|
||||
]) {
|
||||
expect(combined.includes(forbidden), `forbidden token '${forbidden}' must not appear in rig-policy domain source`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
// Slice 09 — rig-policy HTTP route tests.
|
||||
//
|
||||
// HG-4 + HG-SAFE anchored at the route layer:
|
||||
// - PUT/DELETE require operator bearer when configured (HG-4 — agent
|
||||
// path cannot mutate).
|
||||
// - PUT validates the record through the same store validator; an
|
||||
// auto-accept posture is rejected (HG-SAFE runtime defense).
|
||||
// - GET endpoints are open (within the daemon's existing posture).
|
||||
// - resolveEffective returns more-specific binding (HG-3).
|
||||
// - source grep: route file contains no permission-allowlist / auth
|
||||
// / tmux / lifecycle identifier (HG-SAFE source-level).
|
||||
//
|
||||
// BLOCKING-1 fix (guard verdict qitem-20260518043346): PUT body shape
|
||||
// is `{ mode, record }`; record itself is the 10-field schema with no
|
||||
// `mode` inside.
|
||||
//
|
||||
// BLOCKING-2 fix (guard verdict qitem-20260518043346): the route
|
||||
// REJECTS `global_host` with a non-empty qualifier as
|
||||
// `qualifier_forbidden` on GET / PUT / DELETE. No hidden scope
|
||||
// inference; no global-host mutation via a stray path segment.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigPolicyStore } from "../src/domain/rig-policy/rig-policy-store.js";
|
||||
import { rigPolicyRoutes } from "../src/routes/rig-policy.js";
|
||||
import type { OperatorContextMode, OperatorContextModeRecord } from "../src/domain/rig-policy/rig-policy-types.js";
|
||||
|
||||
function makeRecord(overrides?: Partial<OperatorContextModeRecord>): OperatorContextModeRecord {
|
||||
return {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "fast",
|
||||
inspection_depth: "forensic",
|
||||
update_detail: "verbose",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "normal",
|
||||
scope: "qitem",
|
||||
expiry_or_stale_rule: "re_confirm_on_long_gap",
|
||||
evidence_citation: "operator confirmed debug",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function putBody(mode: OperatorContextMode, recordOverrides?: Partial<OperatorContextModeRecord>): unknown {
|
||||
return { mode, record: makeRecord(recordOverrides) };
|
||||
}
|
||||
|
||||
function buildApp(db: Database.Database, bearer: string | null): { app: Hono; store: RigPolicyStore } {
|
||||
const store = new RigPolicyStore(db);
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("rigPolicyStore" as never, store);
|
||||
await next();
|
||||
});
|
||||
app.route("/api/rig-policy", rigPolicyRoutes({ bearerToken: bearer }));
|
||||
return { app, store };
|
||||
}
|
||||
|
||||
describe("rig-policy HTTP routes — slice 09", () => {
|
||||
let db: Database.Database;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("GET /defaults returns the 6×7 + default-scope + DEFAULT_STALE_RULE", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/defaults");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
recommendedModeDefaults: Record<string, unknown>;
|
||||
recommendedDefaultScope: Record<string, string>;
|
||||
defaultStaleRule: string;
|
||||
};
|
||||
expect(Object.keys(body.recommendedModeDefaults).sort()).toEqual(["away", "debug", "desk", "focus", "mobile", "sleep"]);
|
||||
expect(body.recommendedDefaultScope.debug).toBe("qitem");
|
||||
expect(body.defaultStaleRule).toBe("re_confirm_on_long_gap");
|
||||
});
|
||||
|
||||
it("HG-4: PUT requires the operator bearer when configured", async () => {
|
||||
const { app } = buildApp(db, "operator-token");
|
||||
const res = await app.request("/api/rig-policy/bindings/qitem/q-1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(putBody("debug")),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("HG-4 (BLOCKING-1): PUT accepts { mode, record } and round-trips via GET", async () => {
|
||||
const { app } = buildApp(db, "operator-token");
|
||||
const put = await app.request("/api/rig-policy/bindings/qitem/q-1", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer operator-token",
|
||||
},
|
||||
body: JSON.stringify(putBody("debug")),
|
||||
});
|
||||
expect(put.status).toBe(200);
|
||||
const putBodyResp = (await put.json()) as { binding: { mode: string; setBy: string; record: Record<string, unknown> } };
|
||||
expect(putBodyResp.binding.setBy).toBe("operator");
|
||||
expect(putBodyResp.binding.mode).toBe("debug");
|
||||
// The record itself does NOT carry `mode`.
|
||||
expect(putBodyResp.binding.record.mode).toBeUndefined();
|
||||
|
||||
const get = await app.request("/api/rig-policy/bindings/qitem/q-1");
|
||||
expect(get.status).toBe(200);
|
||||
const getBody = (await get.json()) as { binding: { mode: string; record: Record<string, unknown> } };
|
||||
expect(getBody.binding.mode).toBe("debug");
|
||||
expect(getBody.binding.record.mode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("BLOCKING-1: PUT body missing `mode` is rejected with body_shape_invalid", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/bindings/qitem/q-1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ record: makeRecord() }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("body_shape_invalid");
|
||||
});
|
||||
|
||||
it("BLOCKING-1: PUT body that smuggles `mode` inside record is rejected as Unknown field", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/bindings/qitem/q-1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ mode: "debug", record: { ...makeRecord(), mode: "debug" } }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string; errors?: string[] };
|
||||
expect(body.error).toBe("validation_failed");
|
||||
expect(body.errors?.some((e) => e.includes(`Unknown field "mode"`))).toBe(true);
|
||||
});
|
||||
|
||||
it("HG-SAFE: PUT with permission_prompt_posture='auto_accept' is rejected (runtime defense)", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/bindings/qitem/q-1", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mode: "debug",
|
||||
record: { ...makeRecord(), permission_prompt_posture: "auto_accept" },
|
||||
}),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string; errors?: string[] };
|
||||
expect(body.error).toBe("validation_failed");
|
||||
expect(body.errors?.some((e) => e.includes("permission_prompt_posture"))).toBe(true);
|
||||
});
|
||||
|
||||
it("HG-3 effective-resolution: qitem wins over global_host", async () => {
|
||||
const { app, store } = buildApp(db, null);
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
store.setBinding("qitem", "q-1", "debug", makeRecord({ scope: "qitem" }));
|
||||
|
||||
const r1 = await app.request("/api/rig-policy/effective?qitem=q-1");
|
||||
expect(r1.status).toBe(200);
|
||||
const r1body = (await r1.json()) as { effective: { resolvedScope: string; binding: { mode: string } }; posture: string };
|
||||
expect(r1body.effective.resolvedScope).toBe("qitem");
|
||||
expect(r1body.effective.binding.mode).toBe("debug");
|
||||
expect(r1body.posture).toBe("known");
|
||||
|
||||
const r2 = await app.request("/api/rig-policy/effective?qitem=q-other");
|
||||
const r2body = (await r2.json()) as { effective: { resolvedScope: string; binding: { mode: string } } };
|
||||
expect(r2body.effective.resolvedScope).toBe("global_host");
|
||||
expect(r2body.effective.binding.mode).toBe("sleep");
|
||||
});
|
||||
|
||||
it("Q6 unknown_posture: GET /effective with no matching binding returns null + unknown_posture", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/effective?qitem=q-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { effective: unknown; posture: string };
|
||||
expect(body.effective).toBeNull();
|
||||
expect(body.posture).toBe("unknown_posture");
|
||||
});
|
||||
|
||||
it("GET /bindings returns all bindings", async () => {
|
||||
const { app, store } = buildApp(db, null);
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig" }));
|
||||
const res = await app.request("/api/rig-policy/bindings");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { bindings: Array<{ id: string }> };
|
||||
expect(body.bindings.map((b) => b.id).sort()).toEqual(["global_host:host", "rig:rig-a"]);
|
||||
});
|
||||
|
||||
it("HG-4: DELETE requires operator bearer when configured", async () => {
|
||||
const { app, store } = buildApp(db, "operator-token");
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig" }));
|
||||
const noAuth = await app.request("/api/rig-policy/bindings/rig/rig-a", { method: "DELETE" });
|
||||
expect(noAuth.status).toBe(401);
|
||||
const auth = await app.request("/api/rig-policy/bindings/rig/rig-a", {
|
||||
method: "DELETE",
|
||||
headers: { authorization: "Bearer operator-token" },
|
||||
});
|
||||
expect(auth.status).toBe(200);
|
||||
const body = (await auth.json()) as { removed: boolean };
|
||||
expect(body.removed).toBe(true);
|
||||
});
|
||||
|
||||
it("scope_invalid rejects unknown scope at the route layer", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/bindings/banana/x", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(putBody("debug")),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("scope_invalid");
|
||||
});
|
||||
|
||||
it("qualifier_required: GET /bindings/rig (no qualifier) returns 400", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/bindings/rig");
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("qualifier_required");
|
||||
});
|
||||
|
||||
it("GET /bindings/global_host (no qualifier) reads the host binding", async () => {
|
||||
const { app, store } = buildApp(db, null);
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
const res = await app.request("/api/rig-policy/bindings/global_host");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { binding: { mode: string } };
|
||||
expect(body.binding.mode).toBe("sleep");
|
||||
});
|
||||
|
||||
it("404 on a missing binding", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/bindings/rig/rig-not-there");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
// BLOCKING-2 — global_host with a non-empty qualifier MUST be
|
||||
// rejected at every verb. The route does NOT silently drop the
|
||||
// qualifier into null (that would be hidden scope inference + a
|
||||
// write/delete path against the host binding via a typo'd URL).
|
||||
describe("BLOCKING-2: global_host + non-empty qualifier rejected (no hidden inference)", () => {
|
||||
it("GET /bindings/global_host/unexpected → 400 qualifier_forbidden", async () => {
|
||||
const { app } = buildApp(db, null);
|
||||
const res = await app.request("/api/rig-policy/bindings/global_host/unexpected");
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("qualifier_forbidden");
|
||||
});
|
||||
|
||||
it("PUT /bindings/global_host/unexpected → 400 qualifier_forbidden; the global-host row does NOT mutate", async () => {
|
||||
const { app, store } = buildApp(db, null);
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
const before = store.getBinding("global_host", null)!;
|
||||
const res = await app.request("/api/rig-policy/bindings/global_host/unexpected", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(putBody("debug", { scope: "global_host" })),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("qualifier_forbidden");
|
||||
const after = store.getBinding("global_host", null)!;
|
||||
expect(after.mode).toBe("sleep");
|
||||
expect(after.setAt).toBe(before.setAt);
|
||||
});
|
||||
|
||||
it("DELETE /bindings/global_host/unexpected → 400 qualifier_forbidden; the global-host row is NOT removed", async () => {
|
||||
const { app, store } = buildApp(db, null);
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
const res = await app.request("/api/rig-policy/bindings/global_host/unexpected", {
|
||||
method: "DELETE",
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe("qualifier_forbidden");
|
||||
expect(store.getBinding("global_host", null)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// HG-SAFE source-level — the route file does NOT reference permission
|
||||
// allowlist / runtime config / tmux / lifecycle identifiers. The route
|
||||
// is purely a binding-related surface (HG-SAFE).
|
||||
it("HG-SAFE: rig-policy route source contains no permission / auth-token / tmux / lifecycle identifiers", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const url = await import("node:url");
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const src = fs.readFileSync(
|
||||
path.join(here, "..", "src", "routes", "rig-policy.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
for (const forbidden of [
|
||||
"permissionAllowlist",
|
||||
"permission_allowlist",
|
||||
"runtimeConfig",
|
||||
"runtime_config",
|
||||
"tmuxAdapter",
|
||||
"tmux_session",
|
||||
"session_transport",
|
||||
"auth_token",
|
||||
]) {
|
||||
expect(src.includes(forbidden)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
// Slice 09 — store: persistence + scope-hierarchy resolution.
|
||||
//
|
||||
// HG-3 / HG-4 / HG-5 anchored here:
|
||||
// HG-3 — 4 scopes coexist; more-specific-wins resolution (both directions).
|
||||
// HG-4 — set is operator-only at the store layer (set_by = 'operator' hardcoded; agent code path doesn't exist).
|
||||
// HG-5 — persists across "restart" (close + reopen the db in-memory backing).
|
||||
//
|
||||
// BLOCKING-1 fix from guard verdict qitem-20260518043346: `mode` is
|
||||
// a binding-level field, NOT inside the 10-field record. Every
|
||||
// setBinding call passes mode as the third arg; record holds the
|
||||
// frozen 10 Component-3 settings.
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import type Database from "better-sqlite3";
|
||||
import { createFullTestDb } from "./helpers/test-app.js";
|
||||
import { RigPolicyStore } from "../src/domain/rig-policy/rig-policy-store.js";
|
||||
import {
|
||||
type OperatorContextMode,
|
||||
type OperatorContextModeRecord,
|
||||
type OperatorContextScope,
|
||||
} from "../src/domain/rig-policy/rig-policy-types.js";
|
||||
|
||||
function makeRecord(
|
||||
overrides?: Partial<OperatorContextModeRecord>,
|
||||
): OperatorContextModeRecord {
|
||||
return {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "fast",
|
||||
inspection_depth: "forensic",
|
||||
update_detail: "verbose",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "normal",
|
||||
scope: "qitem",
|
||||
expiry_or_stale_rule: "re_confirm_on_long_gap",
|
||||
evidence_citation: "operator confirmed debug",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RigPolicyStore — slice 09 persistence + resolution", () => {
|
||||
let db: Database.Database;
|
||||
let store: RigPolicyStore;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
store = new RigPolicyStore(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it("HG-4: setBinding accepts a valid record and reads it back; mode is binding-level", () => {
|
||||
const res = store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
expect(res.ok).toBe(true);
|
||||
const got = store.getBinding("global_host", null);
|
||||
expect(got?.mode).toBe("sleep");
|
||||
expect(got?.setBy).toBe("operator");
|
||||
// The record itself does NOT carry `mode` — that's the BLOCKING-1
|
||||
// discriminator from guard verdict qitem-20260518043346.
|
||||
expect((got?.record as unknown as Record<string, unknown>)["mode"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("HG-2 + validator: setBinding rejects a record with an unknown field", () => {
|
||||
const res = store.setBinding(
|
||||
"global_host",
|
||||
null,
|
||||
"desk",
|
||||
{ ...makeRecord({ scope: "global_host" }), extra: 1 } as unknown,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("HG-2 + validator: setBinding rejects a record that smuggles `mode` inside the record (unknown field)", () => {
|
||||
const res = store.setBinding(
|
||||
"global_host",
|
||||
null,
|
||||
"desk",
|
||||
{ ...makeRecord({ scope: "global_host" }), mode: "desk" } as unknown,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.errors.some((e) => e.includes(`Unknown field "mode"`))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-1: setBinding rejects an invalid mode name (validateModeName)", () => {
|
||||
const res = store.setBinding("global_host", null, "Sleep", makeRecord({ scope: "global_host" }));
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.errors.some((e) => e.includes(`mode="Sleep"`))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-SAFE: setBinding rejects a record whose permission_prompt_posture is auto_accept (runtime defense)", () => {
|
||||
const candidate = {
|
||||
...makeRecord({ scope: "global_host" }),
|
||||
permission_prompt_posture: "auto_accept",
|
||||
} as unknown;
|
||||
const res = store.setBinding("global_host", null, "desk", candidate);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.errors.some((e) => e.includes("permission_prompt_posture"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-4 invariant: global_host scope requires null qualifier", () => {
|
||||
const res = store.setBinding(
|
||||
"global_host",
|
||||
"rig-1" as unknown as null,
|
||||
"desk",
|
||||
makeRecord({ scope: "global_host" }),
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("HG-4 invariant: non-global scope requires a non-empty qualifier", () => {
|
||||
for (const scope of ["rig", "workstream", "qitem"] as const) {
|
||||
const empty = store.setBinding(scope, "", "desk", makeRecord({ scope }));
|
||||
expect(empty.ok).toBe(false);
|
||||
const nullQ = store.setBinding(scope, null, "desk", makeRecord({ scope }));
|
||||
expect(nullQ.ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a record whose scope field disagrees with the binding scope (no silent mismatch)", () => {
|
||||
const res = store.setBinding(
|
||||
"rig",
|
||||
"rig-a",
|
||||
"focus",
|
||||
makeRecord({ scope: "qitem" }),
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) {
|
||||
expect(res.errors.some((e) => e.includes("scope mismatch"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("setBinding upserts: re-set the same (scope, qualifier) replaces the binding (mode + record)", () => {
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig", evidence_citation: "v1" }));
|
||||
store.setBinding("rig", "rig-a", "debug", makeRecord({ scope: "rig", evidence_citation: "v2" }));
|
||||
const got = store.getBinding("rig", "rig-a");
|
||||
expect(got?.mode).toBe("debug");
|
||||
expect(got?.record.evidence_citation).toBe("v2");
|
||||
expect(store.listBindings().filter((b) => b.id === "rig:rig-a").length).toBe(1);
|
||||
});
|
||||
|
||||
it("listBindings returns all rows", () => {
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig" }));
|
||||
store.setBinding("qitem", "q-1", "debug", makeRecord({ scope: "qitem" }));
|
||||
expect(store.listBindings().map((b) => b.id).sort()).toEqual([
|
||||
"global_host:host",
|
||||
"qitem:q-1",
|
||||
"rig:rig-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("deleteBinding removes and reports whether a row existed", () => {
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig" }));
|
||||
expect(store.deleteBinding("rig", "rig-a")).toBe(true);
|
||||
expect(store.deleteBinding("rig", "rig-a")).toBe(false);
|
||||
expect(store.getBinding("rig", "rig-a")).toBeNull();
|
||||
});
|
||||
|
||||
// HG-3 — DIRECTION A: more-specific-wins (qitem overrides global_host).
|
||||
it("HG-3 DIRECTION A: qitem-scoped debug overrides global_host-scoped sleep for that qitem", () => {
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
store.setBinding("qitem", "q-1", "debug", makeRecord({ scope: "qitem" }));
|
||||
|
||||
const resolvedForQitem = store.resolveEffective({ qitemId: "q-1" });
|
||||
expect(resolvedForQitem?.binding.mode).toBe("debug");
|
||||
expect(resolvedForQitem?.resolvedScope).toBe("qitem");
|
||||
|
||||
const resolvedForOther = store.resolveEffective({ qitemId: "q-2" });
|
||||
expect(resolvedForOther?.binding.mode).toBe("sleep");
|
||||
expect(resolvedForOther?.resolvedScope).toBe("global_host");
|
||||
});
|
||||
|
||||
// HG-3 — DIRECTION B: the inverse — workstream-scoped focus wins over
|
||||
// rig-scoped desk, and rig-scoped desk wins over global_host-scoped sleep.
|
||||
it("HG-3 DIRECTION B: scope precedence qitem > workstream > rig > global_host (both ways)", () => {
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
store.setBinding("rig", "rig-a", "desk", makeRecord({ scope: "rig" }));
|
||||
store.setBinding("workstream", "ws-1", "focus", makeRecord({ scope: "workstream" }));
|
||||
|
||||
const r1 = store.resolveEffective({ rigId: "rig-a", workstreamId: "ws-1" });
|
||||
expect(r1?.binding.mode).toBe("focus");
|
||||
expect(r1?.resolvedScope).toBe("workstream");
|
||||
|
||||
const r2 = store.resolveEffective({ rigId: "rig-a" });
|
||||
expect(r2?.binding.mode).toBe("desk");
|
||||
expect(r2?.resolvedScope).toBe("rig");
|
||||
|
||||
const r3 = store.resolveEffective({});
|
||||
expect(r3?.binding.mode).toBe("sleep");
|
||||
expect(r3?.resolvedScope).toBe("global_host");
|
||||
|
||||
const r4 = store.resolveEffective({ rigId: "rig-other" });
|
||||
expect(r4?.binding.mode).toBe("sleep");
|
||||
});
|
||||
|
||||
it("resolveEffective returns null when no binding matches (convention §Q6 unknown_posture)", () => {
|
||||
expect(store.resolveEffective({ qitemId: "q-1" })).toBeNull();
|
||||
store.setBinding("rig", "rig-a", "desk", makeRecord({ scope: "rig" }));
|
||||
expect(store.resolveEffective({ rigId: "rig-other" })).toBeNull();
|
||||
});
|
||||
|
||||
// HG-5 — survives "restart" (typed primitive in the shared db handle,
|
||||
// same store pattern as workspace primitive). Test simulates daemon
|
||||
// restart by closing and re-opening the same backing.
|
||||
it("HG-5: rows persist across store-instance lifecycle on the same db handle", () => {
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
store.setBinding("qitem", "q-1", "debug", makeRecord({ scope: "qitem" }));
|
||||
|
||||
const fresh = new RigPolicyStore(db);
|
||||
expect(fresh.listBindings().length).toBe(2);
|
||||
expect(fresh.getBinding("global_host", null)?.mode).toBe("sleep");
|
||||
expect(fresh.getBinding("qitem", "q-1")?.mode).toBe("debug");
|
||||
});
|
||||
|
||||
// HG-4 — set_by is always 'operator' at the store layer. The schema
|
||||
// CHECK constraint also enforces this. There is no agent-set code
|
||||
// path; the store API simply doesn't expose one.
|
||||
it("HG-4: set_by is always 'operator' on every row", () => {
|
||||
store.setBinding("rig", "rig-a", "focus", makeRecord({ scope: "rig" }));
|
||||
const row = db.prepare(`
|
||||
SELECT set_by FROM operator_context_mode_bindings WHERE id = ?
|
||||
`).get("rig:rig-a") as { set_by: string };
|
||||
expect(row.set_by).toBe("operator");
|
||||
});
|
||||
|
||||
// HG-4 + BLOCKING-1: `mode` is persisted in its own column (not in
|
||||
// record_json) so the binding's identity is a typed-by-schema TEXT
|
||||
// column with a CHECK constraint, NOT a JSON field that could go
|
||||
// stale or smuggle additional fields.
|
||||
it("HG-4 (BLOCKING-1): mode is persisted in its own column with a CHECK constraint", () => {
|
||||
store.setBinding("global_host", null, "sleep", makeRecord({ scope: "global_host" }));
|
||||
const row = db.prepare(`
|
||||
SELECT mode, record_json FROM operator_context_mode_bindings WHERE id = ?
|
||||
`).get("global_host:host") as { mode: string; record_json: string };
|
||||
expect(row.mode).toBe("sleep");
|
||||
const parsed = JSON.parse(row.record_json) as Record<string, unknown>;
|
||||
expect(parsed["mode"]).toBeUndefined();
|
||||
expect(Object.keys(parsed).length).toBe(10);
|
||||
});
|
||||
|
||||
// HG-SAFE — defense audit: setBinding writes to ONE table. The store
|
||||
// does not expose any method that touches permission allowlists,
|
||||
// runtime config, or auth surfaces. This test pins the surface area.
|
||||
it("HG-SAFE: RigPolicyStore exposes ONLY binding-related methods (no permission/auth/runtime-config surface)", () => {
|
||||
const expected = new Set([
|
||||
"setBinding",
|
||||
"getBinding",
|
||||
"listBindings",
|
||||
"deleteBinding",
|
||||
"resolveEffective",
|
||||
]);
|
||||
const actual = new Set(
|
||||
Object.getOwnPropertyNames(RigPolicyStore.prototype).filter((m) => m !== "constructor"),
|
||||
);
|
||||
for (const m of actual) {
|
||||
expect(expected.has(m)).toBe(true);
|
||||
}
|
||||
for (const m of expected) {
|
||||
expect(actual.has(m)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Per-scope grep negative: no permission-related identifier in the
|
||||
// source. This anchors the gate-zero "NO permission/runtime-config
|
||||
// write anywhere" rule at the store source level.
|
||||
it("HG-SAFE: rig-policy-store source contains no permission / auth / tmux / lifecycle identifiers", async () => {
|
||||
const fs = await import("node:fs");
|
||||
const path = await import("node:path");
|
||||
const url = await import("node:url");
|
||||
const here = path.dirname(url.fileURLToPath(import.meta.url));
|
||||
const src = fs.readFileSync(
|
||||
path.join(here, "..", "src", "domain", "rig-policy", "rig-policy-store.ts"),
|
||||
"utf-8",
|
||||
);
|
||||
for (const forbidden of [
|
||||
"permissionAllowlist",
|
||||
"permission_allowlist",
|
||||
"runtimeConfig",
|
||||
"runtime_config",
|
||||
"tmuxAdapter",
|
||||
"tmux_session",
|
||||
"session_transport",
|
||||
"auth_token",
|
||||
]) {
|
||||
expect(src.includes(forbidden)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Spot-check qualifier-bearing scopes accept distinct bindings per
|
||||
// qualifier without bleed.
|
||||
it("scope+qualifier keys are independent — different rigs/qitems hold distinct rows", () => {
|
||||
store.setBinding("rig", "rig-a", "desk", makeRecord({ scope: "rig" }));
|
||||
store.setBinding("rig", "rig-b", "focus", makeRecord({ scope: "rig" }));
|
||||
expect(store.getBinding("rig", "rig-a")?.mode).toBe("desk");
|
||||
expect(store.getBinding("rig", "rig-b")?.mode).toBe("focus");
|
||||
});
|
||||
|
||||
// Plus a void usage to make the compiler keep the imported type
|
||||
// surface alive.
|
||||
void (null as unknown as OperatorContextScope);
|
||||
void (null as unknown as OperatorContextMode);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// Slice 09 — type-level discriminators (OPR.0.3.2.9).
|
||||
//
|
||||
// These tests anchor the FROZEN convention's vocabulary + the
|
||||
// HG-SAFE structural block on auto-accept. They are runtime-thin
|
||||
// (most coverage is type-level by virtue of the closed unions), but
|
||||
// they assert the constant arrays match the type unions so a future
|
||||
// drift between the value list and the type alias surfaces here.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
OPERATOR_CONTEXT_MODES,
|
||||
OPERATOR_CONTEXT_SCOPES,
|
||||
SAFE_PERMISSION_PROMPT_POSTURES,
|
||||
SCOPE_SPECIFICITY,
|
||||
STALE_RULES,
|
||||
} from "../src/domain/rig-policy/rig-policy-types.js";
|
||||
|
||||
describe("rig-policy types — slice 09 frozen contract", () => {
|
||||
// HG-1 — six modes, exact, reserved, closed.
|
||||
it("HG-1: exposes exactly the six reserved mode names (lowercase single words)", () => {
|
||||
expect([...OPERATOR_CONTEXT_MODES]).toEqual([
|
||||
"sleep",
|
||||
"desk",
|
||||
"mobile",
|
||||
"away",
|
||||
"focus",
|
||||
"debug",
|
||||
]);
|
||||
for (const m of OPERATOR_CONTEXT_MODES) {
|
||||
expect(m).toMatch(/^[a-z]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
// HG-1 negative — synonyms and numeric aliases are NOT in the list.
|
||||
// The L0–L3 collision warning is load-bearing; namespaced numeric
|
||||
// forms (e.g., `operator:L2`) are equally forbidden.
|
||||
it("HG-1 negative: forbidden synonyms / aliases are not present", () => {
|
||||
const forbidden = [
|
||||
"dnd",
|
||||
"ooo",
|
||||
"commute",
|
||||
"bed",
|
||||
"office",
|
||||
"L0",
|
||||
"L1",
|
||||
"L2",
|
||||
"L3",
|
||||
"operator:L0",
|
||||
"operator:L2",
|
||||
"Sleep",
|
||||
"DEBUG",
|
||||
];
|
||||
for (const f of forbidden) {
|
||||
expect(OPERATOR_CONTEXT_MODES as readonly string[]).not.toContain(f);
|
||||
}
|
||||
});
|
||||
|
||||
// HG-3 — four scopes, exact, reserved.
|
||||
it("HG-3: exposes exactly the four reserved scope names", () => {
|
||||
expect([...OPERATOR_CONTEXT_SCOPES]).toEqual([
|
||||
"global_host",
|
||||
"rig",
|
||||
"workstream",
|
||||
"qitem",
|
||||
]);
|
||||
});
|
||||
|
||||
// HG-3 — scope specificity ranks support "more-specific wins"
|
||||
// resolution (qitem > workstream > rig > global_host).
|
||||
it("HG-3: scope specificity ranks support more-specific-wins resolution", () => {
|
||||
expect(SCOPE_SPECIFICITY.qitem).toBeGreaterThan(SCOPE_SPECIFICITY.workstream);
|
||||
expect(SCOPE_SPECIFICITY.workstream).toBeGreaterThan(SCOPE_SPECIFICITY.rig);
|
||||
expect(SCOPE_SPECIFICITY.rig).toBeGreaterThan(SCOPE_SPECIFICITY.global_host);
|
||||
});
|
||||
|
||||
// HG-SAFE — permission_prompt_posture STRUCTURALLY excludes auto-accept.
|
||||
// The constant list mirrors the union; the union has no auto-accept
|
||||
// literal; therefore no caller can express auto-accept through the
|
||||
// type system. The runtime validator enforces the same for inputs
|
||||
// that bypass types (JSON, env, etc.) — see rig-policy-validator tests.
|
||||
it("HG-SAFE (type-level): SAFE_PERMISSION_PROMPT_POSTURES enumerates the only three safe values; auto-accept is absent", () => {
|
||||
expect([...SAFE_PERMISSION_PROMPT_POSTURES]).toEqual([
|
||||
"normal",
|
||||
"batch_for_human",
|
||||
"do_not_prompt_unless_blocked",
|
||||
]);
|
||||
const forbidden = [
|
||||
"auto_accept",
|
||||
"autoaccept",
|
||||
"auto",
|
||||
"accept_all",
|
||||
"allow_all",
|
||||
"yes_to_all",
|
||||
];
|
||||
for (const f of forbidden) {
|
||||
expect(SAFE_PERMISSION_PROMPT_POSTURES as readonly string[]).not.toContain(f);
|
||||
}
|
||||
});
|
||||
|
||||
// HG-8 — drift rules enumerated; NO silent-switch value exists.
|
||||
// The rule values cause re-confirmation (a question); none of them
|
||||
// ever auto-applies a mode change.
|
||||
it("HG-8: stale rules enumerate re-confirmation triggers; no silent-switch value", () => {
|
||||
expect([...STALE_RULES]).toEqual([
|
||||
"none",
|
||||
"re_confirm_on_long_gap",
|
||||
"re_confirm_on_day_boundary",
|
||||
"re_confirm_on_observed_conflict",
|
||||
]);
|
||||
const forbidden = [
|
||||
"auto_switch",
|
||||
"switch_on_long_gap",
|
||||
"switch_on_day_boundary",
|
||||
"drift_switch",
|
||||
];
|
||||
for (const f of forbidden) {
|
||||
expect(STALE_RULES as readonly string[]).not.toContain(f);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
// Slice 09 — validator (runtime defense + invocation disambiguation).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
REQUIRED_RECORD_FIELDS,
|
||||
disambiguateModeInvocation,
|
||||
validateModeName,
|
||||
validateRecord,
|
||||
} from "../src/domain/rig-policy/rig-policy-validator.js";
|
||||
|
||||
// Per guard BLOCKING-1: the record is the FROZEN Component-3 10-field
|
||||
// settings schema. `mode` (Component 2 vocabulary) lives at the binding
|
||||
// layer, NOT inside this record. validRecord() therefore has exactly
|
||||
// 10 fields and no `mode`.
|
||||
function validRecord(): Record<string, unknown> {
|
||||
return {
|
||||
autonomy_scope: "bounded_continuation",
|
||||
heartbeat_cadence: "fast",
|
||||
inspection_depth: "forensic",
|
||||
update_detail: "verbose",
|
||||
escalation_threshold: "low",
|
||||
concurrency_limit: "serial",
|
||||
permission_prompt_posture: "normal",
|
||||
scope: "qitem",
|
||||
expiry_or_stale_rule: "re_confirm_on_long_gap",
|
||||
evidence_citation: "qitem-20260518000000-abc",
|
||||
};
|
||||
}
|
||||
|
||||
describe("validateRecord — slice 09 frozen contract", () => {
|
||||
it("HG-2: accepts a record with all 10 fields populated by valid enum values", () => {
|
||||
const result = validateRecord(validRecord());
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(REQUIRED_RECORD_FIELDS.every((f) => f in result.record)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// BLOCKING-1 discriminator from guard verdict qitem-20260518043346:
|
||||
// the record MUST be exactly the 10 Component-3 settings fields.
|
||||
// Adding `mode` to the record is rejected as an unknown extra field
|
||||
// (mode lives at the binding boundary, not in the record).
|
||||
it("HG-2: exactly 10 required fields, none named `mode` (Component 3 contract)", () => {
|
||||
expect(REQUIRED_RECORD_FIELDS.length).toBe(10);
|
||||
expect(REQUIRED_RECORD_FIELDS as readonly string[]).not.toContain("mode");
|
||||
const valid = validRecord();
|
||||
expect(Object.keys(valid).length).toBe(10);
|
||||
expect(Object.keys(valid)).not.toContain("mode");
|
||||
});
|
||||
|
||||
it("HG-2: a record that includes `mode` is rejected as an unknown field (mode is a binding-level field)", () => {
|
||||
const result = validateRecord({ ...validRecord(), mode: "debug" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.includes(`Unknown field "mode"`))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// HG-2 negative: missing fields are rejected with all-at-once error
|
||||
// collection (per 3-part error doctrine — operator sees the full
|
||||
// diff in one round, not one-error-per-retry).
|
||||
it("HG-2 negative: a record missing any single field is rejected with a 3-part error", () => {
|
||||
for (const field of REQUIRED_RECORD_FIELDS) {
|
||||
const record = validRecord();
|
||||
delete record[field];
|
||||
const result = validateRecord(record);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.includes(`Missing required field "${field}"`))).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-2 negative: an unknown field is rejected with a closed-schema message (extension blocked)", () => {
|
||||
const record = { ...validRecord(), extra_field: "value" };
|
||||
const result = validateRecord(record);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.includes(`Unknown field "extra_field"`))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
// HG-1 negative: synonyms / numeric aliases rejected at runtime
|
||||
// even though the type system already blocks them at compile time.
|
||||
// This is the defense-in-depth for inputs from outside the typed
|
||||
// surface — JSON files, env vars, HTTP bodies.
|
||||
it("HG-1 runtime negative: synonyms (`dnd`, `ooo`, `bed`) are rejected by validateModeName", () => {
|
||||
for (const bad of ["dnd", "ooo", "bed", "office", "commute"]) {
|
||||
const result = validateModeName(bad);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.includes(`mode="${bad}"`)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-1 runtime negative: numeric / namespaced-numeric aliases are rejected by validateModeName", () => {
|
||||
for (const bad of ["L0", "L1", "L2", "L3", "operator:L0", "operator:L2"]) {
|
||||
const result = validateModeName(bad);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.includes(`mode="${bad}"`)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-1 runtime negative: case variants rejected by validateModeName (lowercase single-word vocabulary)", () => {
|
||||
for (const bad of ["Sleep", "DEBUG", "Mobile", "FOCUS"]) {
|
||||
const result = validateModeName(bad);
|
||||
expect(result.ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-1 positive: validateModeName accepts each of the six reserved modes", () => {
|
||||
for (const m of ["sleep", "desk", "mobile", "away", "focus", "debug"]) {
|
||||
const result = validateModeName(m);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.mode).toBe(m);
|
||||
}
|
||||
});
|
||||
|
||||
// HG-SAFE (runtime) — auto-accept rejected at the validator, not
|
||||
// just at the type system. This is the path JSON / env / HTTP-body
|
||||
// input takes; the runtime block is load-bearing.
|
||||
it("HG-SAFE runtime: permission_prompt_posture='auto_accept' rejected", () => {
|
||||
const forbidden = ["auto_accept", "auto", "accept_all", "allow_all", "yes_to_all"];
|
||||
for (const bad of forbidden) {
|
||||
const result = validateRecord({ ...validRecord(), permission_prompt_posture: bad });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(
|
||||
result.errors.some((e) =>
|
||||
e.includes(`permission_prompt_posture="${bad}"`)
|
||||
&& e.includes("normal, batch_for_human, do_not_prompt_unless_blocked"),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// HG-SAFE positive: only the three documented safe values accepted.
|
||||
it("HG-SAFE positive: accepts exactly the three safe values", () => {
|
||||
for (const safe of ["normal", "batch_for_human", "do_not_prompt_unless_blocked"]) {
|
||||
const result = validateRecord({ ...validRecord(), permission_prompt_posture: safe });
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-3 negative: unknown scope rejected", () => {
|
||||
const result = validateRecord({ ...validRecord(), scope: "all_rigs" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.some((e) => e.includes(`scope="all_rigs"`))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("HG-8 negative: silent-switch stale-rule values rejected (`auto_switch`, etc.)", () => {
|
||||
for (const bad of ["auto_switch", "switch_on_long_gap", "drift_switch"]) {
|
||||
const result = validateRecord({ ...validRecord(), expiry_or_stale_rule: bad });
|
||||
expect(result.ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("evidence_citation rejected when missing or empty", () => {
|
||||
const empty = validateRecord({ ...validRecord(), evidence_citation: "" });
|
||||
expect(empty.ok).toBe(false);
|
||||
const whitespace = validateRecord({ ...validRecord(), evidence_citation: " " });
|
||||
expect(whitespace.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-object inputs early", () => {
|
||||
expect(validateRecord(null).ok).toBe(false);
|
||||
expect(validateRecord("hello").ok).toBe(false);
|
||||
expect(validateRecord([validRecord()]).ok).toBe(false);
|
||||
expect(validateRecord(42).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("reports multiple errors in one pass (all-at-once collection)", () => {
|
||||
const record = {
|
||||
...validRecord(),
|
||||
scope: "BadScope",
|
||||
permission_prompt_posture: "auto_accept",
|
||||
heartbeat_cadence: "instant",
|
||||
};
|
||||
const result = validateRecord(record);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("disambiguateModeInvocation — slice 09 §Component 4 bare-word disambiguation", () => {
|
||||
it("bare reserved word → invocation", () => {
|
||||
for (const m of ["sleep", "desk", "mobile", "away", "focus", "debug"]) {
|
||||
expect(disambiguateModeInvocation(m)).toBe(m);
|
||||
}
|
||||
});
|
||||
|
||||
it("`mode:` prefix → invocation (case-insensitive)", () => {
|
||||
expect(disambiguateModeInvocation("mode: mobile")).toBe("mobile");
|
||||
expect(disambiguateModeInvocation("Mode:debug")).toBe("debug");
|
||||
expect(disambiguateModeInvocation("MODE : sleep")).toBe("sleep");
|
||||
});
|
||||
|
||||
it("word embedded in a sentence → not an invocation (caller treats as topic)", () => {
|
||||
expect(disambiguateModeInvocation("I want to debug the auth flow")).toBeNull();
|
||||
expect(disambiguateModeInvocation("let me grab my mobile")).toBeNull();
|
||||
});
|
||||
|
||||
it("bare word that is NOT a reserved mode → null (caller asks once)", () => {
|
||||
expect(disambiguateModeInvocation("dnd")).toBeNull();
|
||||
expect(disambiguateModeInvocation("commute")).toBeNull();
|
||||
expect(disambiguateModeInvocation("L2")).toBeNull();
|
||||
});
|
||||
|
||||
it("empty / whitespace input → null", () => {
|
||||
expect(disambiguateModeInvocation("")).toBeNull();
|
||||
expect(disambiguateModeInvocation(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("case-insensitive on bare-word reserved modes", () => {
|
||||
expect(disambiguateModeInvocation("DEBUG")).toBe("debug");
|
||||
expect(disambiguateModeInvocation("Sleep")).toBe("sleep");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user