execution_policy.py exists to move three live-execution gates off shell
environment variables and into a signed, Studio-rendered policy file. Its own
docstring is unusually explicit about why:
Env-var gates are a bad governance surface:
· invisible to Studio's UI
· not receipted on change
· require a station restart to toggle
· easy to forget on redeploy → live executions silently disabled
and it states a removal schedule for the compatibility shim it kept:
Env vars stay honored as an OVERRIDE for one release cycle (v0.53
warns; v0.55 removes the fallback)
The fallback was never removed. At station-v0.97 it is still the FIRST branch
of the central gate, evaluated before the policy file is read at all:
def allows_live(ws, capability, workflow_id=None):
# (1) env-var backward compat
for env_name, (section, _key) in _ENV_DEPRECATIONS.items():
if section == capability and _env_override_true(env_name):
return True
# (2)-(4) policy check
st = _read(ws)
...
Because the return is unconditional, it short-circuits both the enable flag and
the per-workflow whitelist. And because get_state() reads only the file,
Studio keeps rendering the capability as disabled while the gate is open —
which is precisely the "invisible to Studio's UI" failure the module was written
to eliminate. deny_reason(), whose docstring says "Keep in sync with
allows_live so the two agree on the reason," also still reports the capability
disabled.
Reproduction steps:
- Create a fresh workspace and set the policy the way an operator would in
Studio → Settings → Live Execution — live DAG execution OFF, and a
whitelist naming some other workflow:
import execution_policy as EP
EP.set_state(ws, {"dag": {"live_workflows_enabled": False,
"live_workflow_ids": ["some_other_workflow"]}})
- Confirm the gate denies, as expected:
EP.allows_live(ws, "dag", workflow_id="payroll_run") -> False
- Set the deprecated variable in the environment, as a systemd unit,
docker-compose file, CI job or shell profile written before v0.55 still
would:
export RAILCALL_ALLOW_LIVE_DAG=1
- Re-run the same three calls and compare the gate against the UI:
EP.allows_live(ws, "dag", workflow_id="payroll_run")
EP.get_state(ws)["dag"]
EP.deny_reason(ws, "dag", workflow_id="payroll_run")
- Then set the policy to enabled with a whitelist that EXCLUDES the workflow
({"live_workflows_enabled": True, "live_workflow_ids": ["only_this_one"]})
and call allows_live for an excluded id, with and without the variable.
Expected:
The variable does nothing, having been removed in v0.55. The operator's saved
policy is the only authority: live DAG execution stays denied while the flag is
off, and a workflow absent from a populated whitelist stays denied. If a
compatibility shim is still intended, the capability it grants must at minimum
be visible in get_state() and reflected in deny_reason(), so the surface the
operator reads matches the gate that actually decides.
Actual:
allows_live(ws, 'dag', workflow_id='payroll_run') = True <- the gate
get_state()['dag']['live_workflows_enabled'] = False <- the UI
get_state()['dag']['live_workflow_ids'] = ['some_other_workflow']
deny_reason(...) = "live dag execution is disabled — open Studio →
Settings → Live Execution and enable it. ..."
The policy file on disk is untouched and still reads
"live_workflows_enabled": false. With the whitelist populated and the workflow
deliberately excluded, allows_live returns False without the variable and True
with it — so a workflow the operator specifically kept off the live list runs
live.
Root cause:
execution_policy.py — _ENV_DEPRECATIONS still maps RAILCALL_ALLOW_LIVE_DAG →
("dag", "live_workflows_enabled") and RAILCALL_MCP_ALLOW_LIVE → ("mcp",
"live_workflows_enabled"), and allows_live() returns True from that loop
before reading the policy. The module's documented removal point (v0.55) is 42
releases behind the current tag. get_state() and deny_reason() consult only
the file, so neither surfaces the override.
Callers that inherit the gate: /api/workflow/dag/run's live-run check,
/api/workflow/apply, the scheduler's per-tick live check in routes/schedules.py
(_policy_allows_live), and agent_gate's live-policy step.
Suggested fix:
Remove _ENV_DEPRECATIONS and the env branch from allows_live(), completing
the migration the module documents as finished at v0.55. On finding a set
variable, log a loud one-time error telling the operator to enable the
capability in Studio and unset the variable — a deploy that silently keeps a
governance gate open is the failure mode the module was written to end.
If a compatibility window really must continue, make the override visible
instead of silent: have _read() fold the env override into the returned state
(so get_state() and therefore Studio show the capability as enabled, with its
source), and have deny_reason() report it. The override must also not
short-circuit the per-workflow whitelist — an operator who has narrowed live
execution to specific ids has expressed a scope the shim should respect.
Honest scope:
This requires the environment variable to be set on the station process, so it
is not a remote or unauthenticated bypass and it is not a privilege escalation
for someone who does not already control the deployment. The realistic path is
the one the module's own docstring names — "easy to forget on redeploy" — a
variable set before v0.53 that survives in a systemd unit, container spec, CI
job or shell profile, after which the operator's Studio settings do not mean
what the page says they mean. The harm is a governance-display divergence with
real consequence: the gate is open, every per-workflow restriction is bypassed,
and every surface the operator can inspect reports it closed.
I am not claiming an effect fires with no other control: the approval policy,
plan-pin, capability scope, team quorum and credential gates all still apply
downstream. This is specifically the live-execution capability gate, and the
whitelist within it, being decided by a value the operator cannot see.
Counter-evidence checked:
- The removal claim is the module's own, in its own docstring, naming a version
(v0.55) — not inferred from a field name or from general deprecation policy.
- The deprecation warning still fires on stderr, so the shim is knowingly
present; the defect is that it still GRANTS, and grants invisibly, long past
its stated end.
- Verified the divergence in both directions: with the capability disabled, and
with it enabled but the workflow excluded from a populated whitelist.
- Verified the policy file on disk is not modified, so this is not a write that
an operator could notice by inspecting state.
- Verified against the real, unmodified module from a clean tarball extraction,
with the environment cleared first so no ambient variable affected the result.
- RAILCALL_MCP_ALLOW_LIVE is a partial exception worth stating plainly: besides
this deprecated shim, mcp_server.py independently reads that same variable as
a current, intended gate, so for the mcp capability the name is not purely
legacy. RAILCALL_ALLOW_LIVE_DAG has no other consumer anywhere in the tree —
it is the clean instance, and the one this report rests on.
Distinctness:
This is the environment-variable branch of execution_policy.allows_live, not
the placement of a policy gate relative to it. It is distinct from the reported
finding about an agent path consulting the coarse allows_live toggle instead of
the approval policy gate (that concerns which gate is consulted; this concerns
allows_live itself returning True before any policy is read). It is distinct
from the MCP apply gate being skipped for providers carrying a mock base, which
is mcp_server.h_apply and its own separate ALLOW_LIVE constant. It is not the
governance-route channel-authorization finding, which concerns which session
channel may reach a mutator. I did not find a community thread about the
execution_policy env-var fallback outliving its documented removal.