Operators had to paste a role prompt into a terminal to start work, and
nothing enforced that the allocator had been consulted first, so two sessions
could reach for the same issue and each believe it was theirs. This adds a
request surface: 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.
Preview (POST /api/v1/requests/preview, and the /requests form) runs five
checks and reports authorize/deny with a reason for each: console
authorization, capability resolution for the desired role, lease availability,
whether the allocator would independently select this work unit, and head
pinning for PR work. It is read-only - it calls the allocator with apply=false
and writes only an audit line. An unauthorized principal never reaches the
allocator or the control-plane DB, so a denial cannot enumerate the queue.
Initiation (POST /api/v1/requests/apply) never assigns the requested item
directly. It runs a dry-run first and proceeds only when the allocator would
independently pick that exact work unit, carrying the dry-run's
candidate_set_fingerprint as a CAS pin; otherwise it returns wait or blocked
and mutates nothing. An active claim on the work unit rejects a duplicate
assign before one is attempted. A returned assignment carries a handoff block
naming the required profile, namespace, and the actions that stay forbidden.
Authorization reuses the #633 model rather than adding a second one. The new
initiate_workflow action is operator-class because its outcome is a claim, not
a Gitea verdict: requesting reviewer or merger work reserves that work but
grants no right to approve or merge. Execution is gated by a new per-action
execution_env_flag (WEBUI_REQUESTS_EXECUTION), deliberately in place of raising
ACTIVE_PHASE - a phase bump would enable execution for every phase-2 action at
once, including ones whose execution path is not implemented. Actions that
declare no flag are unchanged and still report execution_enabled false.
Every preview and apply emits a console audit record correlated to the
resulting assignment by correlation.request_id.
Fail-closed throughout: an unreadable control-plane DB, an incomplete queue
inventory (#758), an allocator that raises, an unpinned PR head, a moved PR
head, and an unconfirmed apply all deny without mutating.
Files:
- webui/request_service.py (new) - request model, preview, initiation
- webui/request_views.py (new) - form and preview rendering, escaped
- tests/test_webui_request_initiation.py (new) - 52 tests
- webui/console_authz.py - initiate_workflow action, execution_wired()
- webui/app.py - /requests, /api/v1/requests/preview, /api/v1/requests/apply
- webui/nav.py - Requests nav entry
- webui/traffic_loader.py - public candidates_from_queue_snapshot alias
- docs/webui-requests.md (new), docs/webui-authz-audit.md
Validation: full suite on this branch 5242 passed, 6 skipped, 899 subtests, 23
failed. Clean master baseline at 2f4dec83 in an equivalent branches/ worktree:
5190 passed, 6 skipped, 867 subtests, the same 23 tests failed. The branch adds
52 passing tests and introduces no new full-suite failure signature.
Closes #643
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
6.8 KiB
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
{
"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 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):
| 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).