Skip to content

Human gates

A human gate is a file — results/gates/<gate_id>.json — that pipeline code opens at a decision point a model must not resolve on its own. Every gated tool refuses while any gate in the session is "status": "open". The only way to close one is authorise_gate(session_dir, gate_id, decision), an orchestrator-only MCP tool that carries opencode's permission: ask on @cosmotron — opencode SUSPENDS that call and shows the user a real confirmation dialogue with the exact gate_id/decision before it runs.

Why a file, not an argument

Every earlier design put the check inside the model's own output, and each one was eventually defeated:

  1. Prose ("never override without an explicit human answer") — ignored.
  2. A mandatory reason argument — the orchestrator authored "GLASS synthetic mock simulation — large B-mode excess is a known simulation feature" and overrode nine failing checks in one session, then did it again in a later one.
  3. A mandatory user_reply argument, meant to hold the human's verbatim words — the model wrote one in the user's voice ("override bmode_BB and bmode_EB for all bins — this is a GLASS synthetic mock with intrinsic B-modes as part of the injected signal") and the gate passed. The plan gate fell to the same trick later: the orchestrator called the (then still tool-shaped) mark_plan_approved with its own "yes, proceed with the plan" three seconds after writing the plan, with no user message in between.

A string argument is forgeable by definition, no matter how strongly its docstring insists it must be verbatim — the model is the thing writing the string. A gate whose enforcement lives inside the model's own output is not a gate. authorise_gate moves the check outside that output entirely: the approval is a decision made by opencode's own runtime, in a different process, that the model never gets to author.

Lifecycle

sequenceDiagram
    participant Tool as Pipeline tool
    participant Gate as results/gates/<gate_id>.json
    participant Orch as @cosmotron
    participant User as Human
    participant Auth as authorise_gate

    Tool->>Gate: open_gate(kind, gate_id, question, options, evidence)
    Tool-->>Orch: needs_input { gate_id, options, ... }
    opt gate carries NEW evidence the human hasn't seen (systematics, cost)
        Orch->>User: relay verbatim, wait
        User-->>Orch: decision
    end
    Orch->>Auth: authorise_gate(session_dir, gate_id, decision)
    Note over Auth: permission: ask — opencode suspends here
    User-->>Auth: confirm in the dialogue
    Auth->>Gate: status: "resolved", decision, authorised_by
    Orch->>Tool: re-dispatch the step
    Tool->>Gate: require_gates_clear() — now passes

open_gate is idempotent on gate_id: re-opening an already-open gate does not reset it, so a re-dispatch of the same failing step never loses an in-flight authorisation. decision must be one of the gate's own recorded options — an enum, not free text, so a misleading string cannot be smuggled through the dialogue the way a forged reason/user_reply was.

The dialogue is the single approval interaction. For plan, registry, and registration gates the orchestrator shows the gate's summary in chat as information — never as a question — and calls authorise_gate in the same turn: the user's click in the confirmation dialogue is the one and only approval. Asking "do you approve?" in chat first and then popping a dialogue for the thing just approved is a double-ask, and the flow is explicitly built not to do it. Rejecting the dialogue is the revision path. opencode's permission reply can carry a feedback message, but its TUI only offers that text box when the requesting session has a parentID — a subagent session. @cosmotron is the root session, so its rejection is wordless and ends the turn. Nothing is lost: the gate stays open and the pipeline stays locked, so a rejected decision can never take effect, and your next chat message is read as the instruction — the revision request for a plan gate, the option you want for a registry one. The orchestrator never re-issues a rejected decision, and every gate summary ends with a line reminding you to reject and then say what you want. systematics and cost gates DO relay first, because they carry evidence the human hasn't seen yet (α/p_rescaled/verdict, or a batch count) and have branches (fix-upstream/run-rotations/ override, or reduce-to-4/cancel) that a bare confirm/decline dialogue can't express on its own — the chat relay is what tells the human which branch to pick before the dialogue asks them to confirm it.

Gate kinds

Kind Opened by Safe default? Persisted as a file?
plan write_plan (every fresh draft opens plan-approval — approval required before ANY pipeline step) approve yes
registry ingest_to_session (data matches a registered dataset — reuse decision required BEFORE standardising) ignore the registry yes
registration ingest_to_session (first successful ingest of a previously unseen dataset — persist? decision required BEFORE downstream tools run) ignore (ephemeral session) yes
systematics build_systematics_evidence (a FAILing check) none yes
cost run_bmode_rotation_ensemble_from_session (a large local fan-out) reduce the batch count yes
nside / probe / role-conflict / session-dir-not-found / remote-site-conflict bootstrap_session none no — see below
cores run_inference (needs_cores) none no — see below

Only plan, registry, registration, systematics, and cost are ever persisted as gate files. nside, probe, role-conflict, session-dir-not-found, remote-site-conflict, and cores are conceptually the same kind of decision — no safe default, must always reach a human — but each resolves by the caller re-calling with corrected input (nside=, probe=, session_dir=, ncores=, or fixing the mixed-site data ref), never by authorise_gate. A gate only ever closed by fixing its own root cause would stay open forever once the input is fixed, permanently blocking every later step on a stray file nobody will ever authorise — so these keep their original, already-correct needs_input behaviour instead.

Every gated tool return carries a gate_id string — persisted or not, bootstrap_session and ingest_to_session both include the id in their needs_input payload (e.g. "nside", "registry-<basename>") so the orchestrator relays the exact string the tool returned. Fabricating one from prose (reg-gate-<name>, registry-<invented> prefixes) is a common small-model failure the id-in-return contract exists to prevent. Never construct a gate_id from a dataset_id or an ad-hoc prefix — always relay the string the tool handed back.

The registry gate's options enum lists every candidate dataset id plus the literals "register-new" and "ignore". "register-new" and "ignore" both standardise the run's own data fresh (no wrong-science risk); a candidate-id decision links the session to the registered products with no recompute.

Evidence, and re-opening on a new episode

open_gate records MACHINE evidence — α, p_rescaled, the L1 verdict, candidate dataset ids — never model-authored prose. If a check later fails again with different evidence (say, bmode_BB is fixed, then fails again for an unrelated reason), the gate reopens rather than silently trusting the earlier resolution: a resolved gate whose evidence no longer matches what is on file is a new episode of the same decision point, not the one a human actually looked at.

The escape hatch for headless and Slurm runs

COSMOTRON_GATES is an environment variable, read once at process start and cached — never read from task text or a repo file, both of which a model running inside the same session can write.

COSMOTRON_GATES=skip:registry,cost
COSMOTRON_GATES=skip:plan
COSMOTRON_GATES=accept:systematics:bmode_BB,bmode_EB
  • skip:<kind>[,<kind>...] auto-applies a skippable gate's own recorded safe default. skip:systematics, skip:nside, skip:probe, and skip:role_conflict are startup errors — none of those kinds has a safe default, so silently accepting the clause would mean silently accepting a failure or a guess.
  • accept:systematics:<check_name>[,<check_name>...] proceeds despite one specifically named FAILing check. A bare accept:systematics with no check named is also a startup error — a blanket "accept everything" is not expressible, on purpose.

A policy-resolved gate still flips the underlying check (for systematics) or takes the recorded safe action (for registry/cost) exactly as a human decision would — authorised_by is stamped "policy:COSMOTRON_GATES=..." instead of "human:tui-dialogue", and build_review_evidence reports the two differently so a headless run's output labels itself; it never reads the same as a live confirmation.

Propagation: a local background job inherits the parent process's environment automatically, so a policy set on the MCP server reaches it with no extra wiring. A remote (Slurm) job builds a fresh environment on the cluster and does not inherit anything — the sbatch script generator exports COSMOTRON_GATES explicitly when it is set locally.

The plan gate (formerly the exception)

Plan approval used to be the one human decision deliberately not built this way: mark_plan_approved was a plain tool that recorded an approval already given in chat, on the theory that the primary agent's chat channel reaches the human directly. That theory failed exactly the way the earlier string-argument gates did — the orchestrator authored its own approval reply and called the tool with no user message in between. mark_plan_approved no longer exists on the MCP surface: write_plan now opens a plan gate (gate_id: "plan-approval", options ["approve", "revise"]), every rewrite re-opens it as a fresh episode, and only authorise_gate's confirmation dialogue closes it. The plan-hash binding survives unchanged — an approval belongs to the exact steps the human saw, and editing them voids it. A task that declares skip_gates: true approves mechanically (no gate, no dialogue), recorded as approved_via: "skip_gates: true (declared in task)"; COSMOTRON_GATES=skip:plan does the same for headless runs.

See also