# Web console requests: intent preview and workflow initiation (#643) **Phase 2. Preview is always live and always read-only. Initiation is wired but denied until an operator opts in.** Before this surface, starting role work meant pasting a prompt into a terminal and trusting the operator to have checked the allocator first. Nothing enforced that check, so two sessions could reach for the same issue and each believe it was theirs. This page replaces the paste with a *request*: a desired role, an issue or PR, and a stated intent, answered by an authorization decision and — on confirmation — an exclusive assignment from the allocator. | Concern | Module | |---------|--------| | Request model, preview, initiation | `webui/request_service.py` | | Form and preview rendering | `webui/request_views.py` | | Authorization | `webui/console_authz.py` (`initiate_workflow`) | | Audit | `webui/console_audit.py` | | Ownership substrate | `allocator_service.py` + `control_plane_db.py` | ## Surfaces | Path | Method | Purpose | |------|--------|---------| | `/requests` | GET | Request form | | `/requests` | POST | Render an intent preview. **Never assigns.** | | `/api/v1/requests/preview` | POST | Intent preview as JSON | | `/api/v1/requests/apply` | POST | Initiate — confirmed, audited, allocator-owned | The HTML form has no initiate button on purpose. Initiating requires a confirmed POST to `/api/v1/requests/apply`, so a stray form submission cannot reserve work as a side effect. ## The request ```json { "desired_role": "author", "work_kind": "issue", "work_number": 643, "intent_summary": "implement request preview and initiation", "remote": "prgs", "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools", "expected_head_sha": null } ``` `desired_role` is one of `author`, `reviewer`, `merger`, `reconciler`, `controller`. `work_kind` is `issue` or `pr`. `remote`/`org`/`repo` default to the first project in the registry when omitted; when neither the request nor the registry resolves them, the request is rejected rather than pointed at some other repository. `intent_summary` is required — it is what the audit record states as the reason — and is truncated to 500 characters. Parsing rejects rather than corrects. An unknown role, an unknown work kind, a non-positive number, or a missing intent each return `400` with a `reason_code` and the offending `field`. ## Preview Five checks, each with its own verdict, reason code, and detail: | Check | Passes when | |-------|-------------| | `authorization` | The console principal holds `operator` or above | | `capability` | The desired role maps to a declared profile and MCP namespace | | `lease_availability` | No active claim holds the work unit | | `next_safe_action` | The allocator would independently select this exact work unit | | `head_pin` | PR work resolves to a head SHA, and a supplied SHA still matches | A preview also returns the role's `allowed_actions` and `prohibited_actions` (from `allocator_service.ROLE_ACTIONS`), the `required_profile` and `required_namespace` the work must run under, and a `correlation_id` that ties the preview to its audit record and to any assignment that follows. Preview is read-only in the strict sense: it calls the allocator with `apply=false` and writes nothing but an audit line. An unauthorized principal never reaches the allocator or the control-plane DB at all, so a denial cannot be used to enumerate the queue. ## Initiation `POST /api/v1/requests/apply` refuses in this order, and every refusal returns before any assignment is attempted: | Condition | Outcome | Status | |-----------|---------|--------| | Unparseable request | `invalid_request` | 400 | | Not authorized, or execution not wired | `denied` | 403 | | `confirm` not set | `denied` / `confirmation_required` | 409 | | Work unit already claimed | `blocked` / `duplicate_assignment` | 409 | | Allocator would select other work | `wait` / `not_next_safe_work` | 409 | | Allocator declines on apply | `blocked` or `wait` | 409 | | Evidence unavailable | `wait` / `evidence_unavailable` | 503 | | Assigned | `assigned_work` | 201 | A success returns the assignment plus a `handoff` block naming the profile, the namespace, and the actions that stay forbidden — enough for the operator to continue in the right MCP namespace without guessing. ### Why apply runs the allocator twice The allocator is the only source of exclusive ownership (#600 / #613), and it selects work; it does not take orders. So `apply` runs a dry-run first and proceeds only when the allocator would independently pick the requested work unit. If it would not, the request reports `wait` and mutates nothing. A request is therefore a *confirmation* of the allocator's decision, never an override of it. The apply call carries the dry-run's `candidate_set_fingerprint` as a CAS pin (#776), so a queue that changed between the two calls fails closed rather than assigning against a stale view. The result is checked again on the way out: an assignment naming a different work unit is not read as success. ### Fail-closed defaults - An unreadable control-plane DB denies. It is never treated as "nothing holds this work unit". - An incomplete queue inventory denies (#758). Ranking a partial candidate set can select the wrong work. - An allocator that raises denies. - PR work with no resolvable head SHA denies; a supplied SHA that no longer matches denies with `head_moved`. ## Enabling initiation Execution is wired off. Set `WEBUI_REQUESTS_EXECUTION=1` to enable it for the `initiate_workflow` action only — see [`webui-authz-audit.md`](webui-authz-audit.md) for why this is an action-scoped flag rather than a phase bump. With the variable unset, `apply` returns `403` with `reason_code: unauthorized` no matter who asks. Enabling execution does **not** enable approvals or merges. Those are phase 3 console actions and remain forbidden in every path here; the console reserves work and hands off, and the MCP role profile enforces what that role may then do. ## Audit Every preview and every apply emits a console audit record (schema in [`webui-authz-audit.md`](webui-authz-audit.md)): | Event | `result` | |-------|----------| | Preview | `previewed` | | Refusal at any stage | `denied` | | Assignment created | `succeeded` | `correlation.request_id` carries the request's `correlation_id`, and a successful record's `metadata` carries `assignment_id` and `lease_id`, so an assignment can be traced back to the intent that produced it. The operator's `intent_summary` travels in `metadata` and passes through the standard redaction pass before persistence like every other field. ## Non-goals - No browser-initiated approve or merge, in this phase or any other. - No bypass of allocator exclusive ownership; no self-selection of work. - No auto-start from raw monitoring incidents (#612 stays downstream).