Auto mode: re-key GovernanceSurface + privilege clamp off server-derived execution_mode

Problem / context

!249463 (merged) keys the local-surface GovernanceSurface and the privilege clamp off the client-sent environment, which is spoofable: a client can assert its own environment to pick a different rule column or dodge the clamp (an unrecognized/absent value falls through to :web, the most permissive path). Per ADR-008, before governance flags go GA the surface and clamp must derive from non-spoofable, server-derived signals.

The substrate exists: !248339 (merged) shipped the sealed FlowExecutionAuthorizer::Classification, and !250574 (merged) persists it as the execution_mode column.

Design (resolved 2026-08-20)

Two server-derived signals, kept separate (not collapsed into one):

  • execution_mode (binary: client / background) = trust + interactivity. client = a human is present / interactive (includes the interactive web UI); background = unattended (CI). Drives the privilege clamp.
  • surface (3-way: web / local / background) = which policy column applies (web_access / local_access / background_access). Kept 3-way so the web UI retains its own distinct policy column (web_access), separate from CLI/IDE (local_access).

This chunk does NOT collapse surface selection onto the binary execution_mode. It makes the existing 3-way surface selection server-derived (removing the spoofable client-environment dependency) and uses execution_mode as the trust clamp. web_access stays a live, distinct column.

Verified code facts: GovernanceSurface maps web/ambient to :web (→ web_access, has ask) unless the background flag is on and the flow is allowlisted, then :background (→ background_access, askallow); ide/chat/chat_partiallocal_access. Naming trap: environment: web/ambient are labeled "background environments" (web = legacy ambient), NOT the interactive web UI. Interactive Duo Chat comes in as chat/chat_partiallocal_access today.

Caveat: the interactive web UI must be derivable server-side as the web surface (and as client execution_mode), not via spoofable client environment or caller_can_execute (the web path currently hardwires caller_can_execute: false).

Scope (Rails)

  • Make the 3-way surface selection server-derived instead of reading client environment. Precursor call sites to retarget: the governance_surface = (workflow && workflow.environment) || params[:environment].presence || :web line in workflows.rb; the environment threading in workflow_context_generation_service.rb / create_and_start_workflow_service.rb.
  • Use the persisted execution_mode (from !250574 (merged)) as the trust clamp in update_agent_privileges_service.rb / create_workflow_service.rb.
  • Read the signals at the :ws token mint (which reads the persisted workflow row, not the creation-time classification).
  • Consolidate the duplicated surface-resolution logic into a single authority, removing the scattered || :web fallbacks (this also resolves the external-surface split-brain, #618093).
  • Preserve web_access as a live, distinct column; do not fold web into local_access.
  • Handle pre-flip rows: flag-off client-executable runs persist execution_mode: background (see the !250574 (merged) discussion on nil vs background).

Dependencies

  • !250574 (merged) (persisted execution_mode) must land first.
  • Pre-GA blocker on rollout #613544; overlaps with #618093 (external-surface split-brain).

Acceptance criteria

  • Surface selection and the privilege clamp derive from server-side signals, not client environment.
  • A client asserting environment can no longer dodge the clamp or pick its own column.
  • web_access, local_access, background_access all remain reachable; web keeps a distinct policy column.
  • A single surface-resolution authority; no scattered || :web fallbacks.
  • Signals read correctly at the :ws mint; pre-flip background rows handled.
Edited by 🤖 GitLab Bot 🤖