Skip to content
Merged
5 changes: 3 additions & 2 deletions docs/architecture/slice-dag.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,8 @@ shape:
spawns a sandboxed one-shot check-runner Job to execute the
repo's configured checks at the integration-branch tip and
blocks PR-open on a red verdict; staged rollout via
`EGG_SLICE_GREEN_GATE`, fail-open on infra errors, including
`EGG_SLICE_GREEN_GATE`, default `log` (observe, never block),
fail-open on infra errors, including
infra-signature-tagged reds inside check execution, #3417) — calls
`GatewayClient.create_slice_pr` with `base` resolved from the
slice's DAG parent (root → latest completed chain tip, else the
Expand Down Expand Up @@ -1058,7 +1059,7 @@ on parse failure. The green-gate knobs below are read directly via
| `EGG_ORCH_STACKED_PR_RECONCILER_INTERVAL_SECONDS` | float | 30.0 | Reconciler polling cadence for orphaned child PRs. |
| `EGG_ORCH_CROSS_REPO_MERGE_GATE_MAX_ATTEMPTS` | int | 240 | Poll-attempt budget for the cross-repo merge-sequencing gate (#3393) before a never-merging upstream escalates to a HITL hold; ~2h at the default reconciler cadence. See [Cross-repo merge-sequencing hold](#cross-repo-merge-sequencing-hold-two-tier). |
| `EGG_SLICE_BASE_ANCESTRY_GATE` | str | `on` | Operator kill switch for the admission-time base-ancestry gate (#3541 — see [Root linearization & the base-ancestry gate](#root-linearization--the-base-ancestry-gate-3541)): any of `off`/`0`/`false`/`no` (case-insensitive, whitespace-tolerant) disables the gate; any other value (including unset) leaves it enabled. |
| `EGG_SLICE_GREEN_GATE` | str | `off` | Per-slice green gate rollout switch (#3398): `off` skips the gate entirely; `log` runs the repo's configured checks at the slice tip and logs a red verdict without blocking; `on` blocks slice PR-open on a red verdict. Case-insensitive, with aliases — `on` also accepts `1`/`true`/`yes`, and `log` also accepts `log-only`/`log_only`. Unknown values resolve to `off`. |
| `EGG_SLICE_GREEN_GATE` | str | `log` | Per-slice green gate rollout switch (#3398): `off` skips the gate entirely; `log` (the default) runs the repo's configured checks at the slice tip and logs a red verdict without blocking; `on` blocks slice PR-open on a red verdict. Case-insensitive, with aliases — `on` also accepts `1`/`true`/`yes`, `log` also accepts `log-only`/`log_only`, and `off` also accepts `0`/`false`/`no`. Disabling takes an explicit `off` value; unset or unrecognised values resolve to `log`, so a typo can neither start blocking slices nor silently disable the gate. Note `log` still *runs* the checks and waits for the runner pod, so slice-close latency grows by the check duration (bounded by `EGG_SLICE_GREEN_GATE_TIMEOUT_SECONDS`). |
| `EGG_SLICE_GREEN_GATE_SKIP_CHECKS` | str (comma-separated) | `security` | Configured check *names* (from `repositories.yaml` `checks`) the gate skips. |
| `EGG_SLICE_GREEN_GATE_TIMEOUT_SECONDS` | int | 1800 | Wall-clock budget for the check-runner pod (spawn-to-terminal); a hung suite degrades to fail-open rather than wedging the slice close. |
| `EGG_SLICE_GREEN_GATE_INFRA_FAIL_OPEN` | str | `on` | Infra-red fail-open (#3417): the runner tags red checks whose full output matches an exact infra signature (the sandbox git wrapper's gateway-down / missing-env / session-auth errors, the kernel's ENOSPC message) or whose process died by SIGKILL; a verdict where *every* red check is infra-tagged fails open instead of blocking, and mixed verdicts block on the genuine reds only. `off`/`0`/`false`/`no` restores strict every-red-blocks behavior; any other value resolves to `on`. |
Expand Down
2 changes: 1 addition & 1 deletion docs/development/STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ orchestrator/
├── slice_scheduler.py # Wave-based scheduler for the implement-phase slice DAG: computes execution waves, caps concurrency, two-tier max_cycles accounting, failure-cascade detection (#2137)
├── stacked_pr_reconciler.py # Stacked-PR rebase reconciler: detects child slice PRs whose base branch was deleted after a parent merge and retargets them via gateway rebase_onto (#2137)
├── cross_repo_merge_gate.py # Cross-repo merge-sequencing gate for multi-repo pipelines: auto-readies (or HITL-holds) a dependent slice's draft PR once its cross-repo upstream PR merges; rides the stacked-PR reconciler cadence (#3393 slice-5)
├── slice_green_gate.py # Per-slice green gate: sandboxed one-shot Job runs the repo's configured checks at the integration-branch tip and blocks PR-open on red; staged rollout via EGG_SLICE_GREEN_GATE (off/log/on), fail-open on infra errors (#3398) and on infra-signature-tagged reds inside check execution (#3417)
├── slice_green_gate.py # Per-slice green gate: sandboxed one-shot Job runs the repo's configured checks at the integration-branch tip and blocks PR-open on red; staged rollout via EGG_SLICE_GREEN_GATE (off/log/on, default log), fail-open on infra errors (#3398) and on infra-signature-tagged reds inside check execution (#3417)
├── action_guards.py # Formal BRC state machine action guards (preconditions for propose/ack/nack/confirm/withdraw)
├── approval_matrix.py # Per-reviewer ACK/NACK matrix for BRC consensus
├── attestation_schemas.py # Attestation payload validation for BRC proposals
Expand Down
6 changes: 4 additions & 2 deletions orchestrator/evidence_gatherer.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@
# ---------------------------------------------------------------------------
#
# The shared-evidence prefix ships behind the same ``off -> log -> on`` staged
# flag every #3523 behaviour-shift rides, resolved EXACTLY like
# ``slice_green_gate.green_gate_mode()``: an operator typo must degrade to
# flag every #3523 behaviour-shift rides, using that shared pattern but keeping
# an ``off``-default (``slice_green_gate.green_gate_mode()`` now defaults to
# ``log`` and degrades unknown to ``log`` — this resolver deliberately does
# not): an operator typo must degrade to
# "reviewer prompts unchanged", never to "one gatherer silently anchors every
# lens". The resolver lives HERE, with the feature's core, and is imported by
# the S7 wiring (``_criteria.py`` assembly seam, ``consensus_wrapper.py`` log
Expand Down
15 changes: 10 additions & 5 deletions orchestrator/review_findings_verdict.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@
on escalation) is the point.

**Staged rollout.** The whole path is gated behind ``EGG_REVIEW_FINDINGS_MODE``,
resolved EXACTLY like ``slice_green_gate.green_gate_mode()`` (``off`` default,
unknown => ``off``, ``log`` records the computed-vs-legacy verdict into the BRC
resolved with the shared staged ``off``/``log``/``on`` pattern but keeping an
``off``-default (unknown => ``off``; note ``slice_green_gate.green_gate_mode()``
now defaults to ``log`` and degrades unknown to ``log`` — this resolver
deliberately does not, so a typo leaves the legacy path authoritative). ``log``
records the computed-vs-legacy verdict into the BRC
artifacts without acting, ``on`` uses the computed verdict). Everything in this
module is a pure function of its inputs; it never reads/writes matrix state or
the environment except through :func:`review_findings_mode`. The caller (a
Expand Down Expand Up @@ -77,9 +80,11 @@
def review_findings_mode() -> Literal["off", "log", "on"]:
"""Resolve the operator switch to one of ``off`` / ``log`` / ``on``.

Resolved EXACTLY like ``slice_green_gate.green_gate_mode()``: unknown
values resolve to ``off`` so an operator typo degrades to "legacy path
unchanged", never to "computed verdict silently drives consensus".
Shares the staged ``off``/``log``/``on`` shape but keeps an ``off``-default:
unknown values resolve to ``off`` so an operator typo degrades to "legacy
path unchanged", never to "computed verdict silently drives consensus".
(Unlike ``slice_green_gate.green_gate_mode()``, which now defaults to
``log`` and degrades unknown values to ``log`` + a warning.)
"""
raw = os.environ.get(FINDINGS_MODE_ENV_VAR, "off").strip().lower()
if raw in _ENABLED_VALUES:
Expand Down
14 changes: 9 additions & 5 deletions orchestrator/review_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -475,8 +475,10 @@ def register_phase_graph(phase: str, graph: ReviewGraph) -> None:
# (``agent_model_resolution.resolve_agent_model`` imports :func:`risk_router_mode`
# and :func:`resolve_risk_decision` from here).
#
# Everything rides ONE staged flag, ``EGG_RISK_ROUTER``, resolved EXACTLY like
# ``slice_green_gate.green_gate_mode()`` (``off`` default, unknown => ``off``):
# Everything rides ONE staged flag, ``EGG_RISK_ROUTER``, resolved with the
# shared staged ``off``/``log``/``on`` pattern but keeping an ``off``-default
# (unknown => ``off``; ``slice_green_gate.green_gate_mode()`` now defaults to
# ``log`` and degrades unknown to ``log`` — this resolver deliberately does not):
# * ``off`` — inert. The live graph + efforts are byte-identical to legacy.
# * ``log`` — compute the would-be gated graph / tier / effort and record it
# (:func:`risk_route_log_record` + a structured log line), but
Expand All @@ -497,9 +499,11 @@ def register_phase_graph(phase: str, graph: ReviewGraph) -> None:
def risk_router_mode() -> Literal["off", "log", "on"]:
"""Resolve the ``EGG_RISK_ROUTER`` switch to ``off`` / ``log`` / ``on``.

Resolved EXACTLY like ``slice_green_gate.green_gate_mode()``: an unknown
value resolves to ``off`` so an operator typo degrades to "router does
nothing" (full graph, legacy effort), never to "silently review less".
Shares the staged ``off``/``log``/``on`` shape but keeps an ``off``-default:
an unknown value resolves to ``off`` so an operator typo degrades to "router
does nothing" (full graph, legacy effort), never to "silently review less".
(Unlike ``slice_green_gate.green_gate_mode()``, which now defaults to
``log`` and degrades unknown values to ``log`` + a warning.)
"""
raw = os.environ.get(RISK_ROUTER_ENV_VAR, "off").strip().lower()
if raw in _RISK_ROUTER_ENABLED_VALUES:
Expand Down
64 changes: 51 additions & 13 deletions orchestrator/slice_green_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,26 @@
OOM and fails open. ``EGG_SLICE_GREEN_GATE_INFRA_FAIL_OPEN=off``
restores the strict every-red-blocks behavior.

Rollout is staged via ``EGG_SLICE_GREEN_GATE``: ``off`` (default) →
``log`` (run checks, log the verdict loudly, never block — the soak mode
while #3301 contract-single-writer is still landing, since a stale
contract snapshot on the slice tip can red contract-hygiene tests for
reasons unrelated to the slice's code) → ``on`` (block).
Rollout is staged via ``EGG_SLICE_GREEN_GATE``: ``off`` → ``log``
(**the default**: run checks, log the verdict loudly, never block) →
``on`` (block).

``log`` is the default rather than ``off`` because a gate nobody runs
verifies nothing: the switch shipped in #3398 defaulting to ``off`` and
was never set in any deployment, so the check-runner path had not
executed once in the ~3 weeks after it landed. Defaulting to ``log``
makes every slice close produce a real verdict — the soak evidence that
``on`` needs — while keeping the blocking decision opt-in. It is also
the soak mode for #3301 contract-single-writer, since a stale contract
snapshot on the slice tip can red contract-hygiene tests for reasons
unrelated to the slice's code.

The cost of the default is real and deliberate: ``log`` mode still
*runs* the checks and still waits for the runner pod, so slice-close
latency grows by the check duration (bounded by
``EGG_SLICE_GREEN_GATE_TIMEOUT_SECONDS``, default 1800s, after which the
gate fails open). ``off`` remains available for deployments that cannot
absorb that.

The check toolchain is the **repo-defined** one, not the sandbox
image's: ``repositories.yaml::build_commands`` builds the repo's pinned
Expand Down Expand Up @@ -106,14 +121,24 @@

logger = get_logger("orchestrator.slice_green_gate")

# Operator switch for the green gate. Three-state, default off during
# rollout (#3398): "off"/unset → gate skipped entirely; "log" → checks
# run and a red verdict is logged loudly but never blocks; "on" → a red
# verdict blocks the slice PR from opening.
# Operator switch for the green gate. Three-state, default "log":
# "off" → gate skipped entirely; "log"/unset → checks run and a red
# verdict is logged loudly but never blocks; "on" → a red verdict blocks
# the slice PR from opening.
GREEN_GATE_ENV_VAR = "EGG_SLICE_GREEN_GATE"

_ENABLED_VALUES = frozenset({"on", "1", "true", "yes"})
_LOG_ONLY_VALUES = frozenset({"log", "log-only", "log_only"})
_DISABLED_VALUES = frozenset({"off", "0", "false", "no"})

# Mode used when the switch is unset or carries a value we do not
# recognise. "log" satisfies both halves of the safety property an
# unparseable switch needs: it never blocks a slice (an operator typo
# must not start failing slices) and it never silently disables the
# gate (a typo must not leave a deployment with less verification than
# the product default). Only an explicit _DISABLED_VALUES entry turns
# the gate off.
_DEFAULT_MODE: Literal["off", "log", "on"] = "log"

# Comma-separated check *names* (from repositories.yaml ``checks``) the
# gate skips. Default skips ``security``: the full scan belongs on the
Expand Down Expand Up @@ -349,15 +374,28 @@ def copy_if_missing(src, dst, **kwargs):
def green_gate_mode() -> Literal["off", "log", "on"]:
"""Resolve the operator switch to one of ``off`` / ``log`` / ``on``.

Unknown values resolve to ``off``: during rollout an operator typo
must degrade to "gate does nothing", never to "gate blocks slices".
Unset resolves to ``_DEFAULT_MODE`` (``log``). Turning the gate off
requires an explicit ``off`` / ``0`` / ``false`` / ``no``; any other
unrecognised value also resolves to ``log`` and logs a warning, so a
typo can neither start blocking slices nor silently drop the
deployment below the product default.
"""
raw = os.environ.get(GREEN_GATE_ENV_VAR, "off").strip().lower()
raw = os.environ.get(GREEN_GATE_ENV_VAR, "").strip().lower()
if not raw:
return _DEFAULT_MODE
if raw in _ENABLED_VALUES:
return "on"
if raw in _LOG_ONLY_VALUES:
return "log"
return "off"
if raw in _DISABLED_VALUES:
return "off"
logger.warning(
"Unrecognised green-gate switch value; falling back to the default mode",
env_var=GREEN_GATE_ENV_VAR,
value=raw,
mode=_DEFAULT_MODE,
)
return _DEFAULT_MODE


def _infra_fail_open_enabled() -> bool:
Expand Down
38 changes: 32 additions & 6 deletions orchestrator/tests/test_slice_green_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Covers:

* ``green_gate_mode`` — the three-state operator switch (off default,
log soak mode, on; unknown values degrade to off).
* ``green_gate_mode`` — the three-state operator switch (log default,
explicit off, on; unrecognised values degrade to the default).
* ``_gate_checks`` / ``_repo_requires_prebuilt`` — config-driven check
selection (skip set, default ``security``) and the prebuilt-toolchain
requirement derived from ``build_commands.persist_dirs``.
Expand Down Expand Up @@ -81,8 +81,14 @@ def gate_env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch:


class TestGreenGateMode:
def test_default_is_off(self, gate_env: pytest.MonkeyPatch) -> None:
assert sgg.green_gate_mode() == "off"
def test_default_is_log(self, gate_env: pytest.MonkeyPatch) -> None:
"""Unset resolves to the product default, not to a disabled gate.

The #3398 switch shipped defaulting to ``off`` and was never set
in any deployment, so the check-runner path never executed. The
default is the rollout.
"""
assert sgg.green_gate_mode() == "log"

@pytest.mark.parametrize("value", ["on", "ON", " true ", "1", "yes"])
def test_enabled_values(self, gate_env: pytest.MonkeyPatch, value: str) -> None:
Expand All @@ -94,11 +100,24 @@ def test_log_values(self, gate_env: pytest.MonkeyPatch, value: str) -> None:
gate_env.setenv(sgg.GREEN_GATE_ENV_VAR, value)
assert sgg.green_gate_mode() == "log"

@pytest.mark.parametrize("value", ["off", "0", "false", "", "banana", "enabled"])
def test_everything_else_is_off(self, gate_env: pytest.MonkeyPatch, value: str) -> None:
@pytest.mark.parametrize("value", ["off", "OFF", " off ", "0", "false", "no"])
def test_disabled_values(self, gate_env: pytest.MonkeyPatch, value: str) -> None:
"""Turning the gate off now takes an explicit disable value."""
gate_env.setenv(sgg.GREEN_GATE_ENV_VAR, value)
assert sgg.green_gate_mode() == "off"

@pytest.mark.parametrize("value", ["", " ", "banana", "enabled", "tru", "onn"])
def test_unrecognised_values_degrade_to_default(
self, gate_env: pytest.MonkeyPatch, value: str
) -> None:
"""A typo can neither block slices nor silently disable the gate.

``"tru"`` / ``"onn"`` are the realistic shapes: an operator
reaching for ``on`` must not land on "gate does nothing".
"""
gate_env.setenv(sgg.GREEN_GATE_ENV_VAR, value)
assert sgg.green_gate_mode() == sgg._DEFAULT_MODE == "log"


# ----------------------------------------------------------------------
# _gate_checks / _repo_requires_prebuilt / _gate_timeout_seconds
Expand Down Expand Up @@ -653,6 +672,13 @@ class TestRunSliceGreenGate:
def test_kill_switch_off_skips_everything(
self, gate_env: pytest.MonkeyPatch, configured_checks: None
) -> None:
"""``off`` still short-circuits before any spawn side effect.

This must set the switch explicitly: since the default became
``log`` the unset case *runs* the gate, so an implicit-off
assertion here would silently start exercising the runner.
"""
gate_env.setenv(sgg.GREEN_GATE_ENV_VAR, "off")
spawner = _spawner()
assert _run_gate(spawner) is None
spawner.gateway.create_worktrees.assert_not_called()
Expand Down
Loading