From 855885afaf46fc2479c17ed1673d3280c61518df Mon Sep 17 00:00:00 2001
From: Abhimanyu Saharan
Date: Thu, 12 Feb 2026 23:05:33 +0530
Subject: [PATCH] feat: add board rule toggles for approval and review
requirements
---
backend/app/api/board_onboarding.py | 38 ++
backend/app/api/tasks.py | 192 ++++++++-
backend/app/models/boards.py | 3 +
backend/app/schemas/boards.py | 6 +
..._add_board_pending_approval_status_gate.py | 55 +++
.../test_board_onboarding_autonomy_toggle.py | 45 ++
backend/tests/test_board_schema.py | 22 +
.../tests/test_tasks_done_approval_gate.py | 395 ++++++++++++++++++
.../src/api/generated/model/boardCreate.ts | 3 +
frontend/src/api/generated/model/boardRead.ts | 3 +
.../src/api/generated/model/boardUpdate.ts | 3 +
.../src/app/boards/[boardId]/edit/page.tsx | 246 +++++++++--
12 files changed, 965 insertions(+), 46 deletions(-)
create mode 100644 backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py
create mode 100644 backend/tests/test_board_onboarding_autonomy_toggle.py
create mode 100644 backend/tests/test_tasks_done_approval_gate.py
diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py
index ae7c70ba..e0ac1a7b 100644
--- a/backend/app/api/board_onboarding.py
+++ b/backend/app/api/board_onboarding.py
@@ -86,6 +86,41 @@ def _parse_draft_lead_agent(
return None
+def _normalize_autonomy_token(value: object) -> str | None:
+ if not isinstance(value, str):
+ return None
+ text = value.strip().lower()
+ if not text:
+ return None
+ return text.replace("_", "-")
+
+
+def _is_fully_autonomous_choice(value: object) -> bool:
+ token = _normalize_autonomy_token(value)
+ if token is None:
+ return False
+ if token in {"autonomous", "fully-autonomous", "full-autonomy"}:
+ return True
+ return "autonom" in token and "fully" in token
+
+
+def _require_approval_for_done_from_draft(draft_goal: object) -> bool:
+ """Enable done-approval gate unless onboarding selected fully autonomous mode."""
+ if not isinstance(draft_goal, dict):
+ return True
+ raw_lead = draft_goal.get("lead_agent")
+ if not isinstance(raw_lead, dict):
+ return True
+ if _is_fully_autonomous_choice(raw_lead.get("autonomy_level")):
+ return False
+ raw_identity_profile = raw_lead.get("identity_profile")
+ if isinstance(raw_identity_profile, dict):
+ for key in ("autonomy_level", "autonomy", "mode"):
+ if _is_fully_autonomous_choice(raw_identity_profile.get(key)):
+ return False
+ return True
+
+
def _apply_user_profile(
auth: AuthContext,
profile: BoardOnboardingUserProfile | None,
@@ -408,6 +443,9 @@ async def confirm_onboarding(
board.target_date = payload.target_date
board.goal_confirmed = True
board.goal_source = "lead_agent_onboarding"
+ board.require_approval_for_done = _require_approval_for_done_from_draft(
+ onboarding.draft_goal,
+ )
onboarding.status = "confirmed"
onboarding.updated_at = utcnow()
diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py
index f43522b7..5a86d69b 100644
--- a/backend/app/api/tasks.py
+++ b/backend/app/api/tasks.py
@@ -42,7 +42,10 @@ from app.schemas.errors import BlockedTaskError
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
from app.services.activity_log import record_activity
-from app.services.approval_task_links import load_task_ids_by_approval
+from app.services.approval_task_links import (
+ load_task_ids_by_approval,
+ pending_approval_conflicts_by_task,
+)
from app.services.mentions import extract_mentions, matches_agent_mention
from app.services.openclaw.gateway_dispatch import GatewayDispatchService
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
@@ -113,6 +116,151 @@ def _blocked_task_error(blocked_by_task_ids: Sequence[UUID]) -> HTTPException:
)
+def _approval_required_for_done_error() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail={
+ "message": (
+ "Task can only be marked done when a linked approval has been approved."
+ ),
+ "blocked_by_task_ids": [],
+ },
+ )
+
+
+def _review_required_for_done_error() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail={
+ "message": (
+ "Task can only be marked done from review when the board rule is enabled."
+ ),
+ "blocked_by_task_ids": [],
+ },
+ )
+
+
+def _pending_approval_blocks_status_change_error() -> HTTPException:
+ return HTTPException(
+ status_code=status.HTTP_409_CONFLICT,
+ detail={
+ "message": (
+ "Task status cannot be changed while a linked approval is pending."
+ ),
+ "blocked_by_task_ids": [],
+ },
+ )
+
+
+async def _task_has_approved_linked_approval(
+ session: AsyncSession,
+ *,
+ board_id: UUID,
+ task_id: UUID,
+) -> bool:
+ linked_approval_ids = select(col(ApprovalTaskLink.approval_id)).where(
+ col(ApprovalTaskLink.task_id) == task_id,
+ )
+ statement = (
+ select(col(Approval.id))
+ .where(col(Approval.board_id) == board_id)
+ .where(col(Approval.status) == "approved")
+ .where(
+ or_(
+ col(Approval.task_id) == task_id,
+ col(Approval.id).in_(linked_approval_ids),
+ ),
+ )
+ .limit(1)
+ )
+ return (await session.exec(statement)).first() is not None
+
+
+async def _task_has_pending_linked_approval(
+ session: AsyncSession,
+ *,
+ board_id: UUID,
+ task_id: UUID,
+) -> bool:
+ conflicts = await pending_approval_conflicts_by_task(
+ session,
+ board_id=board_id,
+ task_ids=[task_id],
+ )
+ return task_id in conflicts
+
+
+async def _require_approved_linked_approval_for_done(
+ session: AsyncSession,
+ *,
+ board_id: UUID,
+ task_id: UUID,
+ previous_status: str,
+ target_status: str,
+) -> None:
+ if previous_status == "done" or target_status != "done":
+ return
+ requires_approval = (
+ await session.exec(
+ select(col(Board.require_approval_for_done)).where(col(Board.id) == board_id),
+ )
+ ).first()
+ if requires_approval is False:
+ return
+ if not await _task_has_approved_linked_approval(
+ session,
+ board_id=board_id,
+ task_id=task_id,
+ ):
+ raise _approval_required_for_done_error()
+
+
+async def _require_review_before_done_when_enabled(
+ session: AsyncSession,
+ *,
+ board_id: UUID,
+ previous_status: str,
+ target_status: str,
+) -> None:
+ if previous_status == "done" or target_status != "done":
+ return
+ requires_review = (
+ await session.exec(
+ select(col(Board.require_review_before_done)).where(col(Board.id) == board_id),
+ )
+ ).first()
+ if requires_review and previous_status != "review":
+ raise _review_required_for_done_error()
+
+
+async def _require_no_pending_approval_for_status_change_when_enabled(
+ session: AsyncSession,
+ *,
+ board_id: UUID,
+ task_id: UUID,
+ previous_status: str,
+ target_status: str,
+ status_requested: bool,
+) -> None:
+ if not status_requested or previous_status == target_status:
+ return
+ blocks_status_change = (
+ await session.exec(
+ select(col(Board.block_status_changes_with_pending_approval)).where(
+ col(Board.id) == board_id,
+ ),
+ )
+ ).first()
+ if not blocks_status_change:
+ return
+ if await _task_has_pending_linked_approval(
+ session,
+ board_id=board_id,
+ task_id=task_id,
+ ):
+ raise _pending_approval_blocks_status_change_error()
+
+
def _truncate_snippet(value: str) -> str:
text = value.strip()
if len(text) <= TASK_SNIPPET_MAX_LEN:
@@ -1447,6 +1595,27 @@ async def _apply_lead_task_update(
else:
await _lead_apply_assignment(session, update=update)
_lead_apply_status(update)
+ await _require_no_pending_approval_for_status_change_when_enabled(
+ session,
+ board_id=update.board_id,
+ task_id=update.task.id,
+ previous_status=update.previous_status,
+ target_status=update.task.status,
+ status_requested="status" in update.updates,
+ )
+ await _require_review_before_done_when_enabled(
+ session,
+ board_id=update.board_id,
+ previous_status=update.previous_status,
+ target_status=update.task.status,
+ )
+ await _require_approved_linked_approval_for_done(
+ session,
+ board_id=update.board_id,
+ task_id=update.task.id,
+ previous_status=update.previous_status,
+ target_status=update.task.status,
+ )
if normalized_tag_ids is not None:
await replace_tags(
@@ -1701,6 +1870,27 @@ async def _finalize_updated_task(
) -> TaskRead:
for key, value in update.updates.items():
setattr(update.task, key, value)
+ await _require_no_pending_approval_for_status_change_when_enabled(
+ session,
+ board_id=update.board_id,
+ task_id=update.task.id,
+ previous_status=update.previous_status,
+ target_status=update.task.status,
+ status_requested="status" in update.updates,
+ )
+ await _require_review_before_done_when_enabled(
+ session,
+ board_id=update.board_id,
+ previous_status=update.previous_status,
+ target_status=update.task.status,
+ )
+ await _require_approved_linked_approval_for_done(
+ session,
+ board_id=update.board_id,
+ task_id=update.task.id,
+ previous_status=update.previous_status,
+ target_status=update.task.status,
+ )
update.task.updated_at = utcnow()
status_raw = update.updates.get("status")
diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py
index 8731128b..286252c5 100644
--- a/backend/app/models/boards.py
+++ b/backend/app/models/boards.py
@@ -39,5 +39,8 @@ class Board(TenantScoped, table=True):
target_date: datetime | None = None
goal_confirmed: bool = Field(default=False)
goal_source: str | None = None
+ require_approval_for_done: bool = Field(default=True)
+ require_review_before_done: bool = Field(default=False)
+ block_status_changes_with_pending_approval: bool = Field(default=False)
created_at: datetime = Field(default_factory=utcnow)
updated_at: datetime = Field(default_factory=utcnow)
diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py
index 3727cd2a..584523b3 100644
--- a/backend/app/schemas/boards.py
+++ b/backend/app/schemas/boards.py
@@ -29,6 +29,9 @@ class BoardBase(SQLModel):
target_date: datetime | None = None
goal_confirmed: bool = False
goal_source: str | None = None
+ require_approval_for_done: bool = True
+ require_review_before_done: bool = False
+ block_status_changes_with_pending_approval: bool = False
class BoardCreate(BoardBase):
@@ -68,6 +71,9 @@ class BoardUpdate(SQLModel):
target_date: datetime | None = None
goal_confirmed: bool | None = None
goal_source: str | None = None
+ require_approval_for_done: bool | None = None
+ require_review_before_done: bool | None = None
+ block_status_changes_with_pending_approval: bool | None = None
@model_validator(mode="after")
def validate_gateway_id(self) -> Self:
diff --git a/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py b/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py
new file mode 100644
index 00000000..f1f687f4
--- /dev/null
+++ b/backend/migrations/versions/c2e9f1a6d4b8_add_board_pending_approval_status_gate.py
@@ -0,0 +1,55 @@
+"""add board rule toggles
+
+Revision ID: c2e9f1a6d4b8
+Revises: e2f9c6b4a1d3
+Create Date: 2026-02-12 23:55:00.000000
+
+"""
+
+from __future__ import annotations
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "c2e9f1a6d4b8"
+down_revision = "e2f9c6b4a1d3"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "boards",
+ sa.Column(
+ "require_approval_for_done",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.true(),
+ ),
+ )
+ op.add_column(
+ "boards",
+ sa.Column(
+ "require_review_before_done",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ )
+ op.add_column(
+ "boards",
+ sa.Column(
+ "block_status_changes_with_pending_approval",
+ sa.Boolean(),
+ nullable=False,
+ server_default=sa.false(),
+ ),
+ )
+
+
+def downgrade() -> None:
+ op.drop_column("boards", "block_status_changes_with_pending_approval")
+ op.drop_column("boards", "require_review_before_done")
+ op.drop_column("boards", "require_approval_for_done")
diff --git a/backend/tests/test_board_onboarding_autonomy_toggle.py b/backend/tests/test_board_onboarding_autonomy_toggle.py
new file mode 100644
index 00000000..9b834239
--- /dev/null
+++ b/backend/tests/test_board_onboarding_autonomy_toggle.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from app.api.board_onboarding import _require_approval_for_done_from_draft
+
+
+def test_require_approval_for_done_defaults_true_without_lead_agent_draft() -> None:
+ assert _require_approval_for_done_from_draft(None) is True
+ assert _require_approval_for_done_from_draft({}) is True
+ assert _require_approval_for_done_from_draft({"lead_agent": "invalid"}) is True
+
+
+def test_require_approval_for_done_stays_enabled_for_non_fully_autonomous_modes() -> None:
+ assert (
+ _require_approval_for_done_from_draft(
+ {"lead_agent": {"autonomy_level": "ask_first"}},
+ )
+ is True
+ )
+ assert (
+ _require_approval_for_done_from_draft(
+ {"lead_agent": {"autonomy_level": "balanced"}},
+ )
+ is True
+ )
+
+
+def test_require_approval_for_done_disables_for_fully_autonomous_choices() -> None:
+ assert (
+ _require_approval_for_done_from_draft(
+ {"lead_agent": {"autonomy_level": "autonomous"}},
+ )
+ is False
+ )
+ assert (
+ _require_approval_for_done_from_draft(
+ {"lead_agent": {"autonomy_level": "fully-autonomous"}},
+ )
+ is False
+ )
+ assert (
+ _require_approval_for_done_from_draft(
+ {"lead_agent": {"identity_profile": {"autonomy_level": "fully autonomous"}}},
+ )
+ is False
+ )
diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py
index 5c27781e..35e60ed4 100644
--- a/backend/tests/test_board_schema.py
+++ b/backend/tests/test_board_schema.py
@@ -76,6 +76,28 @@ def test_board_update_rejects_empty_description_patch() -> None:
BoardUpdate(description=" ")
+def test_board_rule_toggles_have_expected_defaults() -> None:
+ """Boards should default to approval-gated done and optional review gating."""
+ created = BoardCreate(
+ name="Ops Board",
+ slug="ops-board",
+ description="Operations workflow board.",
+ gateway_id=uuid4(),
+ )
+ assert created.require_approval_for_done is True
+ assert created.require_review_before_done is False
+ assert created.block_status_changes_with_pending_approval is False
+
+ updated = BoardUpdate(
+ require_approval_for_done=False,
+ require_review_before_done=True,
+ block_status_changes_with_pending_approval=True,
+ )
+ assert updated.require_approval_for_done is False
+ assert updated.require_review_before_done is True
+ assert updated.block_status_changes_with_pending_approval is True
+
+
def test_onboarding_confirm_requires_goal_fields() -> None:
"""Onboarding confirm should enforce goal fields for goal board types."""
with pytest.raises(
diff --git a/backend/tests/test_tasks_done_approval_gate.py b/backend/tests/test_tasks_done_approval_gate.py
new file mode 100644
index 00000000..ea042c63
--- /dev/null
+++ b/backend/tests/test_tasks_done_approval_gate.py
@@ -0,0 +1,395 @@
+from __future__ import annotations
+
+from typing import Literal
+from uuid import uuid4
+
+import pytest
+from fastapi import HTTPException
+from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
+from sqlmodel import SQLModel
+from sqlmodel.ext.asyncio.session import AsyncSession
+
+from app.api import tasks as tasks_api
+from app.api.deps import ActorContext
+from app.models.agents import Agent
+from app.models.approval_task_links import ApprovalTaskLink
+from app.models.approvals import Approval
+from app.models.boards import Board
+from app.models.gateways import Gateway
+from app.models.organizations import Organization
+from app.models.tasks import Task
+from app.schemas.tasks import TaskUpdate
+
+
+async def _make_engine() -> AsyncEngine:
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:")
+ async with engine.connect() as conn, conn.begin():
+ await conn.run_sync(SQLModel.metadata.create_all)
+ return engine
+
+
+async def _make_session(engine: AsyncEngine) -> AsyncSession:
+ return AsyncSession(engine, expire_on_commit=False)
+
+
+async def _seed_board_task_and_agent(
+ session: AsyncSession,
+ *,
+ task_status: str = "review",
+ require_approval_for_done: bool = True,
+ require_review_before_done: bool = False,
+ block_status_changes_with_pending_approval: bool = False,
+) -> tuple[Board, Task, Agent]:
+ organization_id = uuid4()
+ gateway = Gateway(
+ id=uuid4(),
+ organization_id=organization_id,
+ name="gateway",
+ url="https://gateway.local",
+ workspace_root="/tmp/workspace",
+ )
+ board = Board(
+ id=uuid4(),
+ organization_id=organization_id,
+ gateway_id=gateway.id,
+ name="board",
+ slug=f"board-{uuid4()}",
+ require_approval_for_done=require_approval_for_done,
+ require_review_before_done=require_review_before_done,
+ block_status_changes_with_pending_approval=block_status_changes_with_pending_approval,
+ )
+ task = Task(id=uuid4(), board_id=board.id, title="Task", status=task_status)
+ agent = Agent(
+ id=uuid4(),
+ board_id=board.id,
+ gateway_id=gateway.id,
+ name="agent",
+ status="online",
+ is_board_lead=False,
+ )
+
+ session.add(Organization(id=organization_id, name=f"org-{organization_id}"))
+ session.add(gateway)
+ session.add(board)
+ session.add(task)
+ session.add(agent)
+ await session.commit()
+ return board, task, agent
+
+
+async def _update_task_to_done(
+ session: AsyncSession,
+ *,
+ task: Task,
+ agent: Agent,
+) -> None:
+ await _update_task_status(
+ session,
+ task=task,
+ agent=agent,
+ status="done",
+ )
+
+
+async def _update_task_status(
+ session: AsyncSession,
+ *,
+ task: Task,
+ agent: Agent,
+ status: Literal["inbox", "in_progress", "review", "done"],
+) -> None:
+ await tasks_api.update_task(
+ payload=TaskUpdate(status=status),
+ task=task,
+ session=session,
+ actor=ActorContext(actor_type="agent", agent=agent),
+ )
+
+
+@pytest.mark.asyncio
+async def test_update_task_rejects_done_without_approved_linked_approval() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ board, task, agent = await _seed_board_task_and_agent(session)
+ session.add(
+ Approval(
+ id=uuid4(),
+ board_id=board.id,
+ task_id=task.id,
+ action_type="task.review",
+ confidence=65,
+ status="pending",
+ ),
+ )
+ await session.commit()
+
+ with pytest.raises(HTTPException) as exc:
+ await _update_task_to_done(session, task=task, agent=agent)
+
+ assert exc.value.status_code == 409
+ detail = exc.value.detail
+ assert isinstance(detail, dict)
+ assert detail["message"] == (
+ "Task can only be marked done when a linked approval has been approved."
+ )
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_allows_done_with_approved_primary_task_approval() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ board, task, agent = await _seed_board_task_and_agent(session)
+ session.add(
+ Approval(
+ id=uuid4(),
+ board_id=board.id,
+ task_id=task.id,
+ action_type="task.review",
+ confidence=92,
+ status="approved",
+ ),
+ )
+ await session.commit()
+
+ updated = await tasks_api.update_task(
+ payload=TaskUpdate(status="done"),
+ task=task,
+ session=session,
+ actor=ActorContext(actor_type="agent", agent=agent),
+ )
+
+ assert updated.status == "done"
+ assert updated.assigned_agent_id == agent.id
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_allows_done_with_approved_multi_task_link() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ board, task, agent = await _seed_board_task_and_agent(session)
+ primary_task_id = uuid4()
+ session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
+
+ approval_id = uuid4()
+ session.add(
+ Approval(
+ id=approval_id,
+ board_id=board.id,
+ task_id=primary_task_id,
+ action_type="task.batch_review",
+ confidence=88,
+ status="approved",
+ ),
+ )
+ await session.commit()
+
+ session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
+ await session.commit()
+
+ updated = await tasks_api.update_task(
+ payload=TaskUpdate(status="done"),
+ task=task,
+ session=session,
+ actor=ActorContext(actor_type="agent", agent=agent),
+ )
+
+ assert updated.status == "done"
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_allows_done_without_approval_when_board_toggle_disabled() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ _board, task, agent = await _seed_board_task_and_agent(
+ session,
+ require_approval_for_done=False,
+ )
+
+ updated = await tasks_api.update_task(
+ payload=TaskUpdate(status="done"),
+ task=task,
+ session=session,
+ actor=ActorContext(actor_type="agent", agent=agent),
+ )
+
+ assert updated.status == "done"
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_rejects_done_from_in_progress_when_review_toggle_enabled() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ _board, task, agent = await _seed_board_task_and_agent(
+ session,
+ task_status="in_progress",
+ require_approval_for_done=False,
+ require_review_before_done=True,
+ )
+
+ with pytest.raises(HTTPException) as exc:
+ await _update_task_to_done(session, task=task, agent=agent)
+
+ assert exc.value.status_code == 409
+ detail = exc.value.detail
+ assert isinstance(detail, dict)
+ assert detail["message"] == (
+ "Task can only be marked done from review when the board rule is enabled."
+ )
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_allows_done_from_review_when_review_toggle_enabled() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ _board, task, agent = await _seed_board_task_and_agent(
+ session,
+ task_status="review",
+ require_approval_for_done=False,
+ require_review_before_done=True,
+ )
+
+ updated = await tasks_api.update_task(
+ payload=TaskUpdate(status="done"),
+ task=task,
+ session=session,
+ actor=ActorContext(actor_type="agent", agent=agent),
+ )
+
+ assert updated.status == "done"
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_rejects_status_change_with_pending_approval_when_toggle_enabled() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ board, task, agent = await _seed_board_task_and_agent(
+ session,
+ task_status="inbox",
+ require_approval_for_done=False,
+ block_status_changes_with_pending_approval=True,
+ )
+ session.add(
+ Approval(
+ id=uuid4(),
+ board_id=board.id,
+ task_id=task.id,
+ action_type="task.execute",
+ confidence=70,
+ status="pending",
+ ),
+ )
+ await session.commit()
+
+ with pytest.raises(HTTPException) as exc:
+ await _update_task_status(
+ session,
+ task=task,
+ agent=agent,
+ status="in_progress",
+ )
+
+ assert exc.value.status_code == 409
+ detail = exc.value.detail
+ assert isinstance(detail, dict)
+ assert detail["message"] == (
+ "Task status cannot be changed while a linked approval is pending."
+ )
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_allows_status_change_with_pending_approval_when_toggle_disabled() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ board, task, agent = await _seed_board_task_and_agent(
+ session,
+ task_status="inbox",
+ require_approval_for_done=False,
+ block_status_changes_with_pending_approval=False,
+ )
+ session.add(
+ Approval(
+ id=uuid4(),
+ board_id=board.id,
+ task_id=task.id,
+ action_type="task.execute",
+ confidence=70,
+ status="pending",
+ ),
+ )
+ await session.commit()
+
+ updated = await tasks_api.update_task(
+ payload=TaskUpdate(status="in_progress"),
+ task=task,
+ session=session,
+ actor=ActorContext(actor_type="agent", agent=agent),
+ )
+
+ assert updated.status == "in_progress"
+ finally:
+ await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_update_task_rejects_status_change_for_pending_multi_task_link_when_toggle_enabled() -> None:
+ engine = await _make_engine()
+ try:
+ async with await _make_session(engine) as session:
+ board, task, agent = await _seed_board_task_and_agent(
+ session,
+ task_status="inbox",
+ require_approval_for_done=False,
+ block_status_changes_with_pending_approval=True,
+ )
+ primary_task_id = uuid4()
+ session.add(Task(id=primary_task_id, board_id=board.id, title="Primary"))
+
+ approval_id = uuid4()
+ session.add(
+ Approval(
+ id=approval_id,
+ board_id=board.id,
+ task_id=primary_task_id,
+ action_type="task.batch_execute",
+ confidence=73,
+ status="pending",
+ ),
+ )
+ await session.commit()
+
+ session.add(ApprovalTaskLink(approval_id=approval_id, task_id=task.id))
+ await session.commit()
+
+ with pytest.raises(HTTPException) as exc:
+ await _update_task_status(
+ session,
+ task=task,
+ agent=agent,
+ status="in_progress",
+ )
+
+ assert exc.value.status_code == 409
+ finally:
+ await engine.dispose()
diff --git a/frontend/src/api/generated/model/boardCreate.ts b/frontend/src/api/generated/model/boardCreate.ts
index 421ae79d..8850b1d2 100644
--- a/frontend/src/api/generated/model/boardCreate.ts
+++ b/frontend/src/api/generated/model/boardCreate.ts
@@ -21,4 +21,7 @@ export interface BoardCreate {
target_date?: string | null;
goal_confirmed?: boolean;
goal_source?: string | null;
+ require_approval_for_done?: boolean;
+ require_review_before_done?: boolean;
+ block_status_changes_with_pending_approval?: boolean;
}
diff --git a/frontend/src/api/generated/model/boardRead.ts b/frontend/src/api/generated/model/boardRead.ts
index 11f75e2f..2bd5231a 100644
--- a/frontend/src/api/generated/model/boardRead.ts
+++ b/frontend/src/api/generated/model/boardRead.ts
@@ -21,6 +21,9 @@ export interface BoardRead {
target_date?: string | null;
goal_confirmed?: boolean;
goal_source?: string | null;
+ require_approval_for_done?: boolean;
+ require_review_before_done?: boolean;
+ block_status_changes_with_pending_approval?: boolean;
id: string;
organization_id: string;
created_at: string;
diff --git a/frontend/src/api/generated/model/boardUpdate.ts b/frontend/src/api/generated/model/boardUpdate.ts
index 5cefd42f..b70f8bac 100644
--- a/frontend/src/api/generated/model/boardUpdate.ts
+++ b/frontend/src/api/generated/model/boardUpdate.ts
@@ -21,4 +21,7 @@ export interface BoardUpdate {
target_date?: string | null;
goal_confirmed?: boolean | null;
goal_source?: string | null;
+ require_approval_for_done?: boolean | null;
+ require_review_before_done?: boolean | null;
+ block_status_changes_with_pending_approval?: boolean | null;
}
diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx
index 6a5e561d..2320c24b 100644
--- a/frontend/src/app/boards/[boardId]/edit/page.tsx
+++ b/frontend/src/app/boards/[boardId]/edit/page.tsx
@@ -72,6 +72,16 @@ export default function EditBoardPage() {
);
const [boardType, setBoardType] = useState(undefined);
const [objective, setObjective] = useState(undefined);
+ const [requireApprovalForDone, setRequireApprovalForDone] = useState<
+ boolean | undefined
+ >(undefined);
+ const [requireReviewBeforeDone, setRequireReviewBeforeDone] = useState<
+ boolean | undefined
+ >(undefined);
+ const [
+ blockStatusChangesWithPendingApproval,
+ setBlockStatusChangesWithPendingApproval,
+ ] = useState(undefined);
const [successMetrics, setSuccessMetrics] = useState(
undefined,
);
@@ -189,6 +199,14 @@ export default function EditBoardPage() {
boardGroupId ?? baseBoard?.board_group_id ?? "none";
const resolvedBoardType = boardType ?? baseBoard?.board_type ?? "goal";
const resolvedObjective = objective ?? baseBoard?.objective ?? "";
+ const resolvedRequireApprovalForDone =
+ requireApprovalForDone ?? baseBoard?.require_approval_for_done ?? true;
+ const resolvedRequireReviewBeforeDone =
+ requireReviewBeforeDone ?? baseBoard?.require_review_before_done ?? false;
+ const resolvedBlockStatusChangesWithPendingApproval =
+ blockStatusChangesWithPendingApproval ??
+ baseBoard?.block_status_changes_with_pending_approval ??
+ false;
const resolvedSuccessMetrics =
successMetrics ??
(baseBoard?.success_metrics
@@ -238,6 +256,11 @@ export default function EditBoardPage() {
setDescription(updated.description ?? "");
setBoardType(updated.board_type ?? "goal");
setObjective(updated.objective ?? "");
+ setRequireApprovalForDone(updated.require_approval_for_done ?? true);
+ setRequireReviewBeforeDone(updated.require_review_before_done ?? false);
+ setBlockStatusChangesWithPendingApproval(
+ updated.block_status_changes_with_pending_approval ?? false,
+ );
setSuccessMetrics(
updated.success_metrics
? JSON.stringify(updated.success_metrics, null, 2)
@@ -271,7 +294,10 @@ export default function EditBoardPage() {
setMetricsError(null);
let parsedMetrics: Record | null = null;
- if (resolvedSuccessMetrics.trim()) {
+ if (
+ resolvedBoardType !== "general" &&
+ resolvedSuccessMetrics.trim()
+ ) {
try {
parsedMetrics = JSON.parse(resolvedSuccessMetrics) as Record<
string,
@@ -291,9 +317,19 @@ export default function EditBoardPage() {
board_group_id:
resolvedBoardGroupId === "none" ? null : resolvedBoardGroupId,
board_type: resolvedBoardType,
- objective: resolvedObjective.trim() || null,
- success_metrics: parsedMetrics,
- target_date: localDateInputToUtcIso(resolvedTargetDate),
+ objective:
+ resolvedBoardType === "general"
+ ? null
+ : resolvedObjective.trim() || null,
+ require_approval_for_done: resolvedRequireApprovalForDone,
+ require_review_before_done: resolvedRequireReviewBeforeDone,
+ block_status_changes_with_pending_approval:
+ resolvedBlockStatusChangesWithPendingApproval,
+ success_metrics: resolvedBoardType === "general" ? null : parsedMetrics,
+ target_date:
+ resolvedBoardType === "general"
+ ? null
+ : localDateInputToUtcIso(resolvedTargetDate),
};
updateBoardMutation.mutate({ boardId, data: payload });
@@ -408,17 +444,19 @@ export default function EditBoardPage() {
agents.
-
-
- setTargetDate(event.target.value)}
- disabled={isLoading}
- />
-
+ {resolvedBoardType !== "general" ? (
+
+
+ setTargetDate(event.target.value)}
+ disabled={isLoading}
+ />
+
+ ) : null}
@@ -434,37 +472,155 @@ export default function EditBoardPage() {
/>
-
-
-
+ {resolvedBoardType !== "general" ? (
+ <>
+
+
+
-
-
-
+
+
+
+ >
+ ) : null}
+
+
+
+
Rules
+
+ Configure board-level workflow enforcement.
+
+
+
+
+
+
+ Require approval
+
+
+ Require at least one linked approval in{" "}
+ approved state before a task can be marked{" "}
+ done.
+
+
+
+
+
+
+
+ Require review before done
+
+
+ Tasks must move to review before they can be
+ marked done.
+
+
+
+
+
+
+
+ Block status changes with pending approval
+
+
+ Prevent status transitions while any linked approval is in{" "}
+ pending state.
+
+
+
+
{gateways.length === 0 ? (