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:
- Prose ("never override without an explicit human answer") — ignored.
- A mandatory
reasonargument — 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. - A mandatory
user_replyargument, 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_approvedwith 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, andskip:role_conflictare 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 bareaccept:systematicswith 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¶
cosmotron_mcp.gates— the module.- Dataset registry — the registry gate's other half (the registration decision, which is not gated this way; see there for why).
- B-mode validation — what a
systematicsgate on abmode_BB/bmode_EBcheck is actually deciding between. - Determinism & no fabrication — the broader "Python computes, the model only judges numbers" philosophy this extends.