"""Request schemas for /api/v1/flow/* intent verbs."""
from typing import Annotated, Any
from uuid import UUID
from pydantic import BaseModel, BeforeValidator, Field, field_validator
from roboco.foundation.policy.content.validators import coerce_str_list
from roboco.models.base import Complexity
# A ``list[str]`` field that tolerates the Claude SDK's XML-ish tool-input
# parsing: an LLM emitting a bullet list as ``- …
`` elements arrives
# nested (``[[["…"]]]`` / ``[{"item": {"$text": "…"}}, …]``), which a bare
# ``list[str]`` hard-rejects at validation time (the live ``i_will_plan`` crash:
# ``technical_considerations.1 Input should be a valid string``). The
# ``BeforeValidator`` flattens it to a flat ``list[str]`` first — same
# ``coerce_str_list`` used at the intake→DB boundary (Bug 3, MegaTask memory).
# Applied to EVERY LLM-authored list-of-strings on the flow surface so the SDK
# can't crash any of them (acceptance_criteria, issues, files, ac_verdicts,
# technical_considerations, covers_parent_criteria).
StrList = Annotated[list[str], BeforeValidator(coerce_str_list)]
# AC discipline (the 2026-07-07 task-quality defect: restated, over-long
# acceptance criteria). Mirrors roboco.foundation.policy.task_completeness
# so an over-long / over-count AC list is rejected at the request boundary.
_AC_MAX_ITEMS = 7
_AC_MAX_ITEM_CHARS = 200
class SubTaskCreate(BaseModel):
"""A PM sub_task — a delegate target AND a progress-checklist item.
Mirrors DelegateRequest title/description caps so an over-long sub_task
can't bloat the plan (the 2026-07-07 task-quality defect). The server
assigns id + order; callers supply title + description only.
"""
title: str = Field(..., min_length=1, max_length=200)
description: str = Field(..., min_length=20, max_length=600)
class RiskCreate(BaseModel):
"""A {risk, mitigation} entry — what could go wrong and how it's handled."""
risk: str = Field(..., min_length=1, max_length=300)
mitigation: str = Field(..., min_length=1, max_length=600)
class OpenQuestionCreate(BaseModel):
"""An open question the PM wants answered before/during work."""
question: str = Field(..., min_length=1, max_length=300)
answered: bool = False
class GiveMeWorkRequest(BaseModel):
"""Empty request body — agent_id comes from header."""
class IWillWorkOnRequest(BaseModel):
task_id: UUID
plan: str | None = None
# The executing developer's plan is a step checklist (same
# SubTask shape as IWillPlanRequest.sub_tasks). It is both the
# execution plan AND the progress checklist: completing a
# step advances progress. Depth is enforced server-side in
# choreographer._dev_steps_gate (a title with no real description is
# not a step). Server assigns id + order.
steps: list[dict[str, str]] = Field(
default_factory=list,
description="Ordered execution steps — list of {title, description}",
)
# Full parity with IWillPlanRequest so a dev leaf's Plan tab renders the
# same rich structure PMs author. Defaults stay permissive (NOT min_length)
# so re-entry/recovery calls that omit them still pass route validation;
# depth + presence are enforced on FRESH dev claims by
# choreographer._dev_plan_gate. The dev's `plan` doubles as the approach.
technical_considerations: StrList = Field(default_factory=list)
risks: list[dict[str, str]] = Field(default_factory=list)
open_questions: list[dict[str, str | bool]] = Field(default_factory=list)
class OpenPrRequest(BaseModel):
task_id: UUID
class ResolvedFindingInput(BaseModel):
"""One entry in ``i_am_done``'s ``resolved_findings`` — the dev's claim
that a ledger finding is addressed. ``finding_id`` is the id shown in the
rendered note/A2A body (``[F-]``) — a full UUID also matches."""
finding_id: str = Field(..., min_length=1)
commit: str | None = None
note: str | None = Field(default=None, max_length=300)
class IAmDoneRequest(BaseModel):
task_id: UUID
notes: str = ""
resolved_findings: list[ResolvedFindingInput] = Field(default_factory=list)
class IAmBlockedRequest(BaseModel):
task_id: UUID
reason: str = Field(..., min_length=1)
# Pre-gateway parity (G8 part b). The old TaskBlockInput at
# 0c3d15a:roboco/mcp/schemas/__init__.py required blocker_type and
# what_needed so PMs could triage by class. Optional here for
# back-compat with i_am_blocked(reason) callers; supplied fields are
# rendered into the struggle journal entry so the panel surfaces them.
blocker_type: str | None = Field(
default=None,
description=(
"external | internal | question | dependency. Required from "
"newly-spawned agents (per the developer.md verb table); "
"older agents that don't supply it default to 'internal'."
),
)
what_needed: str | None = Field(
default=None,
description="Concrete description of what would unblock the task.",
)
@field_validator("blocker_type", mode="before")
@classmethod
def _blocker_type_enum(cls, v: object) -> object:
if v is None:
return v
if isinstance(v, str) and v.lower() not in {
"external",
"internal",
"question",
"dependency",
}:
raise ValueError(
f"blocker_type must be one of: external | internal | "
f"question | dependency. Got {v!r}."
)
return v
class UnclaimRequest(BaseModel):
task_id: UUID
class ReassignRequest(BaseModel):
"""HTTP body for the cell_pm `reassign` verb.
``new_assignee`` is a developer slug in the caller's own cell (e.g.
``be-dev-2``). The choreographer resolves and validates it.
"""
task_id: UUID
new_assignee: str = Field(..., min_length=1)
class DeclareCoverageRequest(BaseModel):
"""HTTP body for the cell_pm/main_pm `declare_coverage` verb.
``task_id`` is the CHILD to stamp (or the caller's OWN root/coordination
task, for root-owned criteria); ``criteria`` are that task's parent's
acceptance criteria — or its own, in root-owned mode — by id or exact
text (same representation as `delegate`'s `covers_parent_criteria`). The
choreographer validates ownership + unknown criteria.
"""
task_id: UUID
criteria: StrList = Field(..., min_length=1)
class ResumeRequest(BaseModel):
task_id: UUID
class SyncBranchRequest(BaseModel):
"""HTTP body for the dev `sync_branch` verb.
Rebases the task's branch onto its resolved base (parent branch) through the
gate, so a developer whose branch has fallen behind can re-sync without raw
git (which is denied to agents). Git-only — no DB state transition.
"""
task_id: UUID
# Auto-stash (tracked + untracked) instead of refusing DIRTY_WORKSPACE;
# popped back after the rebase. Default False preserves the prior refuse
# behavior for callers that don't opt in.
stash: bool = False
class IAmIdleRequest(BaseModel):
"""Empty request body."""
class ClaimReviewRequest(BaseModel):
task_id: UUID
class PassReviewRequest(BaseModel):
task_id: UUID
notes: str = Field(..., min_length=1)
ac_verdicts: StrList | None = Field(
default=None,
description=(
"One verification entry per acceptance criterion (in criterion "
"order) stating how QA verified it. Every criterion must be "
"covered before a pass is allowed."
),
)
class FailReviewRequest(BaseModel):
task_id: UUID
# Free text — deprecated, one release. Shimmed into file-less findings.
issues: StrList = Field(default_factory=list)
findings: list[dict[str, Any]] = Field(
default_factory=list,
description=(
"Structured revision findings — each {file?, line?, severity "
"(blocker|major|minor|nit), expected, actual, fix?, evidence?}. "
"At least one of findings/issues is required."
),
)
class ClaimPrReviewRequest(BaseModel):
task_id: UUID
class PostPrReviewRequest(BaseModel):
task_id: UUID
body: str = Field(..., min_length=1)
event: str = Field(
default="REQUEST_CHANGES",
description=(
"APPROVE, REQUEST_CHANGES, or COMMENT. The verdict must match the "
"findings: REQUEST_CHANGES needs >=1 finding; APPROVE may not carry a "
"blocker/major finding. Pass APPROVE explicitly to approve a clean PR."
),
)
findings: list[dict[str, Any]] = Field(
default_factory=list,
description=(
"Structured per-criterion findings — each {file, line?, severity "
"(blocker|major|minor|nit), expected, actual}. When provided, the "
"GitHub comment is generated from them in the RoboCo format."
),
)
class ClaimGateReviewRequest(BaseModel):
task_id: UUID
class PrPassRequest(BaseModel):
task_id: UUID
notes: str = Field(..., min_length=1)
class PrFailRequest(BaseModel):
task_id: UUID
# Free text — deprecated, one release. Shimmed into file-less findings.
issues: StrList = Field(default_factory=list)
findings: list[dict[str, Any]] = Field(
default_factory=list,
description=(
"Structured revision findings — each {file?, line?, severity "
"(blocker|major|minor|nit), expected, actual, fix?, evidence?}. "
"At least one of findings/issues is required."
),
)
class ClaimDocTaskRequest(BaseModel):
task_id: UUID
class IDocumentedRequest(BaseModel):
task_id: UUID
notes: str = Field(..., min_length=1)
files: StrList = Field(..., min_length=1)
class TriageRequest(BaseModel):
"""Empty request body."""
class WaiveFindingRequest(BaseModel):
finding_id: UUID
note: str = Field(
...,
min_length=1,
description=(
"Why this finding is waived rather than fixed — recorded on the "
"ledger row and in audit. Only minor/nit findings are waivable."
),
)
class UnblockRequest(BaseModel):
task_id: UUID
reason: str = Field(
...,
min_length=10,
description=(
"Why the block is cleared — recorded as the PM's journal:decision "
"so no separate note(scope='decision') call is needed."
),
)
restore: bool = True
class CompleteRequest(BaseModel):
task_id: UUID
notes: str = Field(..., min_length=1)
class RequestChangesRequest(BaseModel):
task_id: UUID
# Free text — deprecated, one release. Shimmed into file-less findings.
issues: StrList = Field(default_factory=list)
findings: list[dict[str, Any]] = Field(
default_factory=list,
description=(
"Structured revision findings — each {file?, line?, severity "
"(blocker|major|minor|nit), expected, actual, fix?, evidence?}. "
"At least one of findings/issues is required."
),
)
class EscalateUpRequest(BaseModel):
task_id: UUID
reason: str = Field(..., min_length=1)
class EscalateToCeoRequest(BaseModel):
task_id: UUID
reason: str = Field(..., min_length=1)
class IWillPlanRequest(BaseModel):
task_id: UUID
plan: str = Field(..., min_length=1, max_length=2000)
# Pre-gateway parity: Approach is REQUIRED — agents
# could not transition claimed → in_progress without filling this in the
# pre-gateway flow. The Plan tab depends on it; smoke run 3 confirmed
# the empty default lets agents through with thin plans.
# min_length must match choreographer._impl._PM_APPROACH_MIN_LEN. Raised
# 20→150: a 20-char approach was a one-liner; the approach +
# sub_tasks are also the progress checklist, so they must be substantive.
# max_length caps the bloat defect (approach that ran to thousands of
# chars restating the description). Must match the gate ceiling.
approach: str = Field(..., min_length=150, max_length=800)
sub_tasks: list[SubTaskCreate] = Field(
default_factory=list,
description="List of {title, description} — server assigns id + order",
)
technical_considerations: StrList = Field(default_factory=list)
risks: list[RiskCreate] = Field(default_factory=list)
open_questions: list[OpenQuestionCreate] = Field(default_factory=list)
class DelegateRequest(BaseModel):
"""HTTP body for cell_pm + main_pm `delegate` verbs.
Mirrors :data:`roboco.foundation.policy.task_completeness.TASK_AT_CREATE`
so under-filled payloads fail at the request boundary with a 422 — no
silent defaults, no "code"/"medium" fallbacks. Each constraint matches
the hint string returned by the foundation policy.
"""
parent_task_id: UUID
title: str = Field(..., min_length=1, max_length=200)
# 20-char minimum mirrors TASK_AT_CREATE.description (MIN_LENGTH=20).
# Forces a real one-line summary instead of "x" or "see title".
description: str = Field(..., min_length=20)
assigned_to: str = Field(..., min_length=1)
team: str = Field(..., min_length=1)
# task_type, nature, estimated_complexity are EXPLICITLY_DECLARED in
# TASK_AT_CREATE. The 2026-05-08 trace showed agents omitting task_type
# and the old default of 'code' deadlocking the lifecycle; the same
# silent-default trap exists for nature ("technical") and complexity
# ("medium"). Force callers to declare intent.
task_type: str = Field(..., min_length=1)
nature: str = Field(..., min_length=1)
estimated_complexity: Complexity
# acceptance_criteria is required and non-empty; downstream policy
# also denylist-checks each item against placeholder phrases. The
# per-item cap (<=200 chars) + list cap (<=7) mirror the policy so an
# over-long / over-count AC list is rejected at the boundary (422), not
# only by the service-layer completeness check.
acceptance_criteria: StrList = Field(..., min_length=1, max_length=7)
@field_validator("acceptance_criteria")
@classmethod
def _ac_items_bounded(cls, v: list[str]) -> list[str]:
for i, item in enumerate(v):
if len(item.strip()) > _AC_MAX_ITEM_CHARS:
raise ValueError(
f"acceptance_criteria[{i}] is {len(item.strip())} chars "
f"(max {_AC_MAX_ITEM_CHARS}) — a criterion that long is a "
"restated description, not a verifiable outcome. Split it."
)
return v
# Optional per-subtask project override. When omitted, the choreographer
# resolves the project from the parent's Product map for this cell, then
# falls back to the parent's project. Plain optional field — no validator.
project_id: UUID | None = None
# Parent acceptance-criterion ids this subtask is responsible for. Lets the
# coverage + roll-up AC gates verify every parent AC is claimed and satisfied.
covers_parent_criteria: StrList | None = None
# Dev-task collision surface (the multi-level sequencing model — edge kind
# 3). The cell PM states what each dev task touches so the choreographer can
# run SequencingService and wire the dev-task collision DAG (file-overlap
# serializes, migration-adders chain, shared-surface edits run last).
# Optional: a delegate without surfaces joins no collision edges (parallel).
intends_to_touch: StrList | None = None
adds_migration: bool = False
touches_shared: bool = False
# Explicit dependency override (edge the surface rules would miss, e.g. a
# non-collision ordering the PM knows). Optional; wired verbatim as
# dependency_ids on the created dev task.
depends_on: list[UUID] | None = None
# Pre-gateway parity: cross-field validators that catch the most common
# LLM-vs-schema confusions. Pre-gateway lived in
# roboco/mcp/schemas/__init__.py::TaskCreateInput at 0c3d15a.
@field_validator("nature", mode="before")
@classmethod
def _nature_must_be_known(cls, v: object) -> object:
"""Reject invented nature values (e.g., 'standard') with the enum hint."""
if isinstance(v, str) and v.lower() not in {"technical", "non_technical"}:
raise ValueError(
f"nature must be one of: technical | non_technical. Got {v!r}. "
f"This was the 2026-05-11 'standard' regression — drop the "
f"invented value and use the enum."
)
return v
@field_validator("task_type", mode="before")
@classmethod
def _task_type_must_be_known(cls, v: object) -> object:
"""Reject invented task_type values with the enum hint."""
if isinstance(v, str) and v.lower() not in {
"code",
"documentation",
"research",
"planning",
"design",
"administrative",
}:
raise ValueError(
f"task_type must be one of: code | documentation | research | "
f"planning | design | administrative. Got {v!r}."
)
return v
class SubmitUpRequest(BaseModel):
task_id: UUID
notes: str = Field(..., min_length=1)
resolved_findings: list[ResolvedFindingInput] = Field(default_factory=list)
class SubmitRootRequest(BaseModel):
task_id: UUID
notes: str = Field(..., min_length=1)
resolved_findings: list[ResolvedFindingInput] = Field(default_factory=list)