/** * The wire contract for the conductor-owned mutation verbs (#126). * * Everything here is pure: no sockets, no store, no `gh`. The daemon's request * handler and the child's thin client both parse against this one table, so the * two halves of the RPC cannot come to disagree about what a verb takes — and * the adversarial tests can drive the parser directly, without a live daemon. * * Two properties are load-bearing and both are decided here rather than at a * call site: * * - **Identity is never an argument.** {@link RESERVED_VERB_FIELDS} are refused * outright wherever they appear in a payload. `project`, `run`, `issue` and * the caller's role come from *which socket the call arrived on*; a request * that spells them is either buggy or probing, and both deserve an answer * that names what it sent. * - **Closed by default.** A verb declares the roles it accepts and the * arguments it takes. An unlisted role is refused, and an undeclared * argument is refused rather than ignored: a verb that forgets to declare * something must fail shut, not open. */ import { LABEL_REASONS, MERGE_REASONS, RELEASE_REASONS, RELEASE_SHAPES, RESERVED_VERB_FIELDS, REVIEW_REASONS, VERB_NAMES, } from "../types.ts"; import type { ReservedVerbField, SessionRole, VerbName, VerbRefusal } from "../types.ts"; /** One declared argument. Untyped values are refused, never coerced. */ export interface VerbArg { type: "string" | "number"; required: boolean; description: string; /** Closed vocabulary this argument is drawn from, when it has one. */ oneOf?: readonly string[]; } /** * One verb, as both halves of the RPC see it. * * `allowedRoles` is the whole access-control story for the channel: the daemon * resolves a role from the socket and compares it against this set. The * per-verb authority check (`authority.merge`, `authority.release`) is a * *second*, narrower gate on top — a session can be the right kind of caller * and still not be the configured holder. */ export interface VerbSpec { name: VerbName; allowedRoles: readonly SessionRole[]; /** * Whether this verb changes anything. * * Two consequences, both deliberate. A mutating verb is refused while the * fleet is stopped and lands in the ledger; a read is neither. A run that is * finishing its report during a `hold` still has to be able to say what it * saw, and a poll every thirty seconds would bury the ledger's actual news — * the refusals — under its own noise. */ mutating: boolean; /** What the model is told this verb does. Also the registered tool's description. */ description: string; args: Readonly>; /** * What a caller of the wrong kind is told. Worded per verb because the * refusal is the only explanation the model gets, and "role not allowed" does * not tell a worker where merge authority actually lives. */ roleRefusalText: (role: SessionRole) => string; } const RATIONALE_ARG: VerbArg = { type: "string", required: false, description: "Free-form justification. Logged verbatim; nothing branches on it.", }; /** * The dispatch table. Keyed by {@link VerbName}, and the table-driven test * walks `VERB_NAMES` rather than this object's keys, so a verb added to the * vocabulary and not to this table fails a test instead of shipping ungated. */ export const VERB_SPECS: Readonly> = { conductor_push: { name: "conductor_push", mutating: true, allowedRoles: ["worker"], description: "Push this run's own branch to its remote, fast-forward only. The ref, the repository and " + "the credential are the daemon's; there is no force path and no way to name another ref.", args: { // Accepted so a model that states its intent is answered precisely rather // than pushed somewhere else silently. There is no ref this verb can be // talked into: a mismatch is a refusal, never a redirect. ref: { type: "string", required: false, description: "Optional assertion: must be exactly refs/heads/.", }, }, roleRefusalText: (role) => `conductor_push pushes a run's own branch and only a worker owns one; this is a ${role} session.`, }, conductor_pr_create: { name: "conductor_pr_create", mutating: true, allowedRoles: ["worker"], description: "Open the pull request for this run. Head is this run's branch and base is the repo's " + "configured default branch — both are the daemon's, not arguments you choose.", args: { title: { type: "string", required: true, description: "Pull request title." }, body: { type: "string", required: true, description: "Pull request body." }, base: { type: "string", required: false, description: "Optional assertion: must be the repo's configured defaultBranch.", }, head: { type: "string", required: false, description: "Optional assertion: must be this run's branch.", }, }, roleRefusalText: (role) => `conductor_pr_create opens a run's own pull request; this is a ${role} session, which owns no run.`, }, conductor_pr_update_branch: { name: "conductor_pr_update_branch", mutating: true, allowedRoles: ["worker", "orchestrator"], description: "Server-side merge of the base branch into an open pull request that has fallen behind. " + "Destroys nothing and rewrites nothing. A worker may only name its own run's PR.", args: { prUrl: { type: "string", required: true, description: "Full pull request URL. Must belong to this project, and to this run if you are a worker.", }, }, roleRefusalText: (role) => `conductor_pr_update_branch is not open to a ${role} session.`, }, conductor_pr_update: { name: "conductor_pr_update", mutating: true, allowedRoles: ["worker", "orchestrator"], description: "Update this run's pull request title and/or body. Never touches base, head or state — " + "a field you may not change is a field this verb refuses rather than renames. A worker " + "may only name its own run's PR.", args: { prUrl: { type: "string", required: false, description: "Full pull request URL. Required on an orchestrator channel; a run channel infers its own PR.", }, title: { type: "string", required: false, description: "New pull request title. At least one of title/body must be a non-empty string.", }, body: { type: "string", required: false, description: "New pull request body. At least one of title/body must be a non-empty string.", }, rationale: RATIONALE_ARG, }, roleRefusalText: (role) => `conductor_pr_update is not open to a ${role} session.`, }, conductor_pr_merge: { name: "conductor_pr_merge", mutating: true, allowedRoles: ["orchestrator"], description: "Merge one open pull request. The daemon re-reads the live head immediately before merging " + "and refuses on any mismatch with headSha, and one merge is in flight per project at a time. " + "A PR a run of this project opened merges on the holder's authority; a PR no run opened " + "merges only as the orchestrator's own work (author=orchestrator), and only in a routed repo.", args: { prUrl: { type: "string", required: true, description: "Full pull request URL — a run's in this project, or the orchestrator's own (author=orchestrator).", }, headSha: { type: "string", required: true, description: "The exact head you believe you are merging. Re-read live before the merge; a stale one is refused.", }, reason: { type: "string", required: true, description: `Why this merge, from the closed set: ${MERGE_REASONS.join(", ")}.`, oneOf: MERGE_REASONS, }, author: { type: "string", required: false, description: "Optional authorship claim; only 'orchestrator'. States that no run opened this PR — it is " + "the orchestrator's own work. Required for a PR no run in this project opened; it never " + "unlocks a run's PR, and never bypasses the configured holder, the routed-repo scope, the " + "live-head recheck, the green-checks gate, the pause or the ledger.", oneOf: ["orchestrator"], }, rationale: RATIONALE_ARG, }, // The exact sentence #126 asks a refused worker to be given. `authority` // has two holders, so a "not human" test would have let a worker through; // the check compares the caller against the configured holder, and so does // this wording. roleRefusalText: () => "merge authority is the orchestrator's, never a worker session's.", }, conductor_label: { name: "conductor_label", mutating: true, allowedRoles: ["orchestrator"], description: "Add or remove one label from this project's own vocabulary on one of its issues. " + "Lifecycle labels stay the daemon's and are refused here.", args: { issueUrl: { type: "string", required: true, description: "Full issue URL. Must be in this project's tracker repository.", }, label: { type: "string", required: true, description: "A label in this project's own vocabulary.", }, action: { type: "string", required: true, description: "add or remove.", oneOf: ["add", "remove"], }, reason: { type: "string", required: true, description: `Why, from the closed set: ${LABEL_REASONS.join(", ")}.`, oneOf: LABEL_REASONS, }, rationale: RATIONALE_ARG, }, roleRefusalText: (role) => `label authority is the orchestrator's; this is a ${role} session, and a worker never edits the queue.`, }, conductor_release: { name: "conductor_release", mutating: true, allowedRoles: ["orchestrator"], description: "Perform one mediated release step. The caller's role must equal the configured release " + "holder and the per-shape grant must permit it; a worker is refused whatever the config says.", args: { shape: { type: "string", required: true, description: `What is being released: ${RELEASE_SHAPES.join(", ")}.`, oneOf: RELEASE_SHAPES, }, repo: { type: "string", required: true, description: "The routed repository name this release targets.", }, reason: { type: "string", required: true, description: `Why now, from the closed set: ${RELEASE_REASONS.join(", ")}.`, oneOf: RELEASE_REASONS, }, tag: { type: "string", required: false, description: "Tag to prepare or cut, for version-bump-pr, git-tag, and github-release.", }, sha: { type: "string", required: false, description: "Pin the tag to this exact commit instead of whatever the released repo's default branch " + "is at cut time (#695), for git-tag and git-push-tags. The commit must be reachable from " + "that branch, or the release is refused naming it. Omit it and the behaviour is unchanged.", }, artefact: { type: "string", required: false, description: "Package or image, for package-publish. Must be one this project declared.", }, environment: { type: "string", required: false, description: "Deploy target, for deploy. Must be one this project declared.", }, rationale: RATIONALE_ARG, }, roleRefusalText: (role) => `release authority is the orchestrator's, and this is a ${role} session. ` + "A worker session holds no release grant whatever the config says.", }, conductor_install: { name: "conductor_install", mutating: true, allowedRoles: ["orchestrator"], description: "Request the fleet upgrade itself to a published omp-conductor release (#486). The install " + "shape gates it like a release shape and stays with the human by default; with the grant, " + "the daemon validates the version against npm and starts a detached transient unit that " + "pauses, drains, installs the CLI/omp plugin/Herdr plugin and reloads — outside this session " + "and the daemon. The unit never declares its own success: the first tick after the restart " + "verifies version, /healthz, ticks, pane and doctor, and reports through the durable outbox.", args: { version: { type: "string", required: true, description: "The exact published semver to install (e.g. 0.15.11). The request is refused when npm " + "does not expose it with a full gitHead.", }, rationale: RATIONALE_ARG, }, roleRefusalText: (role) => `install authority is the orchestrator's, and this is a ${role} session. ` + "A worker session holds no install grant whatever the config says.", }, conductor_pr_review: { name: "conductor_pr_review", mutating: true, allowedRoles: ["orchestrator"], description: "Return one green, run-owned pull request to its worker with blocking findings (#677). " + "The daemon records the revision durably — findings, exact reviewed head, round number, " + "target run and session — then resumes the SAME OMP session on the existing branch and PR: " + "no close, no reopen, no issue redispatch, no fresh brief. The worker edits and pushes the " + "same branch; a new green head returns to the normal orchestrator review surface. Refused " + "for a stale head, a PR no run of this project owns, a run that is not settled green, or a " + "revision already in flight for the same PR.", args: { prUrl: { type: "string", required: true, description: "Full pull request URL — one a run of this project opened and pushed green. Must be in " + "a routed repository.", }, headSha: { type: "string", required: true, description: "The exact reviewed head. The live head must match it and the checks at it must be green.", }, findings: { type: "string", required: true, description: "Structured blocking findings. Delivered verbatim to the resumed session; the worker is " + "told to address exactly these and push the same branch.", }, reason: { type: "string", required: true, description: `Why this revision, from the closed set: ${REVIEW_REASONS.join(", ")}.`, oneOf: REVIEW_REASONS, }, rationale: RATIONALE_ARG, }, roleRefusalText: (role) => `review authority is the orchestrator's; this is a ${role} session, and a worker never returns its own PR for revision.`, }, conductor_pr_review_clear: { name: "conductor_pr_review_clear", mutating: true, allowedRoles: ["orchestrator"], description: "Record that the durable review findings standing at one exact head are settled, so " + "`conductor_pr_merge` stops refusing that head with merge-blocked-by-review (#913). This is the " + "only clearance reachable at an UNCHANGED head: a re-review at the same head opens or appends to " + "another blocking round rather than superseding one, and nothing settles a round from outside a " + "worker. The clearance applies strictly backwards — it suppresses the evidence recorded up to the " + "moment it is taken, never a finding recorded afterwards — and to that one head only. It bypasses " + "nothing else: authority, pause, base-red-freeze, exact-head, green checks, release composition and " + "single-flight all still apply to the merge. Refused for a PR no run of this project owns, a head " + "that is not the live head, and a head where nothing is blocking.", args: { prUrl: { type: "string", required: true, description: "Full pull request URL — one a run of this project recorded, in a routed repository.", }, headSha: { type: "string", required: true, description: "The exact head being cleared. Must equal the live head; checks are not re-read here " + "(the merge gate re-checks green itself).", }, reason: { type: "string", required: true, description: "Why this evidence is settled, in your own words. Non-empty, recorded verbatim in the " + "ledger and the digest — this is the audit trail for a merge that a review round refused.", }, rationale: RATIONALE_ARG, }, roleRefusalText: (role) => `clearing review findings is the orchestrator's disposition; this is a ${role} session, ` + "and a worker never clears the findings standing against its own pull request.", }, conductor_pr_recover: { name: "conductor_pr_recover", mutating: true, allowedRoles: ["orchestrator"], description: "Recover the missing pull request for a terminal run whose branch was preserved with no PR (#806): " + "the daemon proves the run has no live worker, that no other PR already represents the issue, and that the " + "routed branch still exists at the exact 40-hex head the run recorded, then opens the missing PR from that " + "branch to the repo's configured default branch and records it on the run so review/merge settle normally. " + "Automatically adopts and returns an existing PR at that exact head (idempotent). Refused for a live run, a " + "closed or merged issue/PR, a replaced or force-pushed head, an unrelated open closer, a recorded PR with no " + "readable state, or a repo the project no longer routes.", args: { issueUrl: { type: "string", required: true, description: "Full issue URL of the run to recover. Must be in this project's tracker repository; the run resolved is the newest attempt for it.", }, branch: { type: "string", required: false, description: "Optional assertion: must equal the run's recorded branch.", }, base: { type: "string", required: false, description: "Optional assertion: must equal the repo's configured defaultBranch.", }, headSha: { type: "string", required: false, description: "Optional assertion: must equal the run's recorded exact head.", }, rationale: RATIONALE_ARG, }, roleRefusalText: (role) => `pull-request recovery is the orchestrator's; this is a ${role} session, and a worker's PR is its own to push and leave.`, }, conductor_pr_status: { name: "conductor_pr_status", mutating: false, allowedRoles: ["worker", "orchestrator"], description: "Read one pull request's live state and current head — open, merged, or closed — and the checks " + "available for it (checks are unavailable for a merged or closed PR). Optionally compare that head " + "with one the caller already observed. This is the daemon's own merge-gate verdict, not a summary of it. " + "A poll, not a watcher — call it again after waiting. A worker may omit prUrl and gets its own run's.", args: { prUrl: { type: "string", required: false, description: "Full pull request URL. Omit as a worker to read your own run's.", }, headSha: { type: "string", required: false, description: "Optional: an expected head to compare against; omit it to read the current head. If the " + "branch has moved, the answer names both shas.", }, }, roleRefusalText: (role) => `conductor_pr_status is not open to a ${role} session.`, }, conductor_ci_logs: { name: "conductor_ci_logs", mutating: false, allowedRoles: ["worker", "orchestrator"], description: "Read the failing CI jobs' own log output at one pull request's head. Call this when " + "conductor_pr_status reports a red check you have to fix, INSTEAD of guessing from the check name or " + "re-running the suite locally to see what CI saw. It answers with the failed steps of every failed job " + "across the head's attempts, bounded to the newest evidence and truncated per job when large — so it is " + "cheap in turns but not free in context; read it once and fix from it, do not poll it. Read-only: it " + "changes nothing and is not ledgered. If the logs cannot be read it says so — an unreadable log is never " + "reported as \"no failures\". A worker may omit prUrl and gets its own run's.", args: { prUrl: { type: "string", required: false, description: "Full pull request URL. Omit as a worker to read your own run's.", }, headSha: { type: "string", required: false, description: "Optional: the head you believe you are diagnosing. If the branch has moved since, the call is " + "refused naming both shas rather than handing you logs from a superseded commit.", }, }, roleRefusalText: (role) => `conductor_ci_logs is not open to a ${role} session.`, }, }; export interface VerbRequest { verb: VerbName; args: Record; } export type VerbParse = | { ok: true; request: VerbRequest } | { ok: false; refusal: VerbRefusal; detail: string; verb?: VerbName }; /** What the daemon writes back. One reply per connection, then it closes. */ export interface VerbReply { ok: boolean; verb?: VerbName; refusal?: VerbRefusal; /** What the model reads. Always populated, refusal or not. */ text: string; /** The commit, tag or merge sha an approved call produced, when there is one. */ sha?: string; } function isVerbName(value: unknown): value is VerbName { return VERB_NAMES.some((name) => name === value); } /** * Every reserved field the payload spells, top level or one deep in `args`. * Collected rather than short-circuited: a refusal that names one field of * three teaches a probing client to remove them one at a time. */ export function reservedFieldsIn(payload: Record): ReservedVerbField[] { const args = payload["args"]; const nested = args !== null && typeof args === "object" && !Array.isArray(args) ? (args as Record) : {}; return RESERVED_VERB_FIELDS.filter( (field) => Object.hasOwn(payload, field) || Object.hasOwn(nested, field), ); } /** * Parse one raw request against {@link VERB_SPECS}. * * Fails shut at every step, and names what it saw: an unparseable payload, a * verb nobody registered, an identity field, an argument the verb never * declared, a missing or mistyped one, or a value outside a closed vocabulary. */ export function parseVerbRequest(raw: unknown): VerbParse { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ok: false, refusal: "malformed-argument", detail: "refused: a verb request must be a JSON object.", }; } const payload = raw as Record; // Before the verb is even resolved: a payload carrying identity is refused // whatever it was asking for, because the interesting thing about it is not // which verb it named. const reserved = reservedFieldsIn(payload); if (reserved.length > 0) { return { ok: false, refusal: "identity-in-payload", ...(isVerbName(payload["verb"]) ? { verb: payload["verb"] } : {}), detail: `refused: this request carries ${reserved.map((f) => `"${f}"`).join(", ")}. ` + "Identity is never an argument — the daemon derives the project, the run, the issue and " + "your role from the socket this call arrived on and the verified peer uid. Send the verb " + "and its own arguments only.", }; } const verb = payload["verb"]; if (!isVerbName(verb)) { return { ok: false, refusal: "malformed-argument", detail: `refused: ${JSON.stringify(verb)} is not a conductor verb. Registered: ${VERB_NAMES.join(", ")}.`, }; } const spec = VERB_SPECS[verb]; const rawArgs = payload["args"] ?? {}; if (rawArgs === null || typeof rawArgs !== "object" || Array.isArray(rawArgs)) { return { ok: false, verb, refusal: "malformed-argument", detail: `refused: ${verb} args must be an object.`, }; } const args = rawArgs as Record; const undeclared = Object.keys(args).filter((key) => !Object.hasOwn(spec.args, key)); if (undeclared.length > 0) { return { ok: false, verb, refusal: "unknown-argument", detail: `refused: ${verb} does not take ${undeclared.map((k) => `"${k}"`).join(", ")}. ` + `It takes ${Object.keys(spec.args).join(", ") || "no arguments"}. Undeclared arguments are ` + "refused rather than ignored: a verb that quietly drops one is a verb whose gate you cannot " + "read off its signature.", }; } for (const [key, arg] of Object.entries(spec.args)) { const value = args[key]; if (value === undefined) { if (arg.required) { return { ok: false, verb, refusal: "malformed-argument", detail: `refused: ${verb} needs "${key}".`, }; } continue; } if (typeof value !== arg.type) { return { ok: false, verb, refusal: "malformed-argument", detail: `refused: ${verb} argument "${key}" must be a ${arg.type}, got ${typeof value}.`, }; } if (arg.oneOf !== undefined && !arg.oneOf.some((allowed) => allowed === value)) { // `reason` is the field #129 closed on purpose, so it gets its own // refusal code: an out-of-enum reason is vocabulary drift, not a typo. const reasonDrift = key === "reason"; // Measured on this fleet 2026-08-23: this is the largest single refusal // cause in the ledger — 99 of 241, across the four verbs whose `reason` // is a closed set, still firing. Every one of the 99 carried a real // explanation ("landed directly in PR #940; queue label would re-dispatch // finished work"), which is exactly the audit content the ledger exists // to hold, and 7578 characters of it were discarded. None of the 99 // supplied `rationale` — the field built for it — because the moment a // caller has a sentence to record is the moment they are told only that // `reason` is closed (#968). // // Keyed on the spec rather than a hardcoded verb list: a verb with an // enum `reason` and no `rationale` must not be told to use a field it // would then refuse as `unknown-argument`, turning one wasted round trip // into two. const carriesRationale = reasonDrift && spec.args["rationale"] !== undefined; return { ok: false, verb, refusal: reasonDrift ? "reason-not-in-enum" : "malformed-argument", detail: `refused: ${verb} argument "${key}" must be one of ${arg.oneOf.join(", ")}, ` + `got ${JSON.stringify(value)}.` + (carriesRationale ? ' Pick the enum value that fits and put that sentence in "rationale", which is logged verbatim.' : ""), }; } } return { ok: true, request: { verb, args } }; } /** * The refusal for a caller of the wrong kind, or `undefined` when the role is * listed. Closed by default: a spec with an empty `allowedRoles` refuses * everybody, which is the correct answer for a verb nobody finished wiring. */ export function roleRefusal(spec: VerbSpec, role: SessionRole): string | undefined { return spec.allowedRoles.some((allowed) => allowed === role) ? undefined : spec.roleRefusalText(role); } /** * The JSON Schema the harness advertises for one verb. * * Generated from the same {@link VerbSpec} the daemon validates against, so the * schema the model is shown and the gate it is held to cannot drift — the * failure mode that makes "the tool wouldn't let me" unfalsifiable. */ export function verbParameterSchema(spec: VerbSpec): Record { const properties: Record = {}; const required: string[] = []; for (const [key, arg] of Object.entries(spec.args)) { properties[key] = { type: arg.type, description: arg.description, ...(arg.oneOf === undefined ? {} : { enum: [...arg.oneOf] }), }; if (arg.required) required.push(key); } return { type: "object", properties, required, additionalProperties: false }; }