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 @ 27e7fc1a after 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-cleared 27e7fc1a, 0
commits-after, base 7a12c930 unmoved, 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:
mvschwarzandClaude Opus 4.7 committed 2026-05-17 22:06:52 -07:00
commit 55e01f2e47
19 files changed
+3372 -3

No files matched your search

+16 -1
View File
@@ -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> {
+517
View File
@@ -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 };
+3
View File
@@ -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());
+416
View File
@@ -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;
}
+203
View File
@@ -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;
}
+11
View File
@@ -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");
+6 -1
View File
@@ -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
+2 -1
View File
@@ -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");
});
});