diff --git a/api/schemas/task.py b/api/schemas/task.py index a32b58e..eb4fb89 100644 --- a/api/schemas/task.py +++ b/api/schemas/task.py @@ -2,7 +2,7 @@ import uuid from datetime import date, datetime from typing import Self -from pydantic import BaseModel, ConfigDict, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from api.models.task import TaskPriority, TaskStatus from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin @@ -46,7 +46,8 @@ class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin): needs_human: bool = False intervention_note: str | None = None decision_id: str | None = None - blocked_on: str | None = None + # Bounded by tasks.blocked_on String(200); longer text belongs in blocking_reason. + blocked_on: str | None = Field(default=None, max_length=200) wait_until: date | None = None parent_task_id: uuid.UUID | None = None @@ -69,7 +70,8 @@ class TaskUpdate(TaskStatusMixin, TaskFlavorMixin): needs_human: bool | None = None intervention_note: str | None = None decision_id: str | None = None - blocked_on: str | None = None + # Bounded by tasks.blocked_on String(200); longer text belongs in blocking_reason. + blocked_on: str | None = Field(default=None, max_length=200) wait_until: date | None = None parent_task_id: uuid.UUID | None = None # Token passthrough — three tiers (highest precision wins): diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 3299a91..33e4906 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -247,6 +247,23 @@ FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALI STATUS_ORDER: dict[str, int] = dict(TASK_STATUS_ORDER) +def normalise_decision_type(meta: dict) -> str: + """Map a file's decision_type onto the hub's two values, made and pending. + + Repos record decided outcomes with words like ``approved``; the hub rejects + anything else with a 422, which left those decisions unindexed. + """ + value = str(meta.get("decision_type") or "").strip().lower() + if value in {"made", "pending"}: + return value + status = str(meta.get("status") or "").strip().lower() + if status == "resolved" or value in { + "approved", "accepted", "decided", "rejected", "declined", "resolved", + }: + return "made" + return "pending" + + def historical_view_paths(repo_slug: str) -> set[str]: """Repo-relative paths of bound historical views that share a parent row. @@ -3879,7 +3896,7 @@ def fix_repo( "id": desired_record_id, "title": title, "topic_id": topic_id, - "decision_type": meta.get("decision_type", "pending"), + "decision_type": normalise_decision_type(meta), "description": meta.get("description"), "rationale": meta.get("agent_recommendation") or meta.get("rationale"), } diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 9b7b75e..7ba99e6 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -2598,3 +2598,20 @@ def test_historical_split_views_do_not_supply_parent_status(): } assert "workplans/archived/260707-custodian-WP-0003-event-bridge.md" not in views assert historical_view_paths("the-custodian") == set() + + +@pytest.mark.parametrize( + ("meta", "expected"), + [ + ({"decision_type": "approved", "status": "resolved"}, "made"), + ({"decision_type": "approved"}, "made"), + ({"decision_type": "made"}, "made"), + ({"decision_type": "pending", "status": "open"}, "pending"), + ({"status": "resolved"}, "made"), + ({}, "pending"), + ], +) +def test_decision_type_maps_onto_hub_values(meta, expected): + from scripts.consistency_check import normalise_decision_type + + assert normalise_decision_type(meta) == expected diff --git a/tests/test_task_wait_qualifiers_schema.py b/tests/test_task_wait_qualifiers_schema.py new file mode 100644 index 0000000..1e2e1fb --- /dev/null +++ b/tests/test_task_wait_qualifiers_schema.py @@ -0,0 +1,10 @@ +import pytest +from pydantic import ValidationError + +from api.schemas.task import TaskUpdate + + +def test_blocked_on_longer_than_column_is_rejected_not_a_500(): + TaskUpdate(blocked_on="x" * 200) + with pytest.raises(ValidationError, match="blocked_on"): + TaskUpdate(blocked_on="x" * 201) diff --git a/workplans/ADHOC-2026-10-06.md b/workplans/ADHOC-2026-10-06.md index 90f30d9..29e2866 100644 --- a/workplans/ADHOC-2026-10-06.md +++ b/workplans/ADHOC-2026-10-06.md @@ -9,6 +9,7 @@ owner: claude-code topic_slug: state-hub created: "2026-10-06" updated: "2026-10-06" +state_hub_workstream_id: "b73dbcde-1e01-5ed0-a8ed-9f3feac1f15b" --- ## Stop historical split views from writing their parent's status @@ -17,6 +18,7 @@ updated: "2026-10-06" id: STATE-WP-ADHOC-2026-10-06-T01 status: done priority: medium +state_hub_task_id: "09274552-5e91-5b2c-93bb-797704d52761" ``` Activity's custodian-WP-0003a/b/c split views carry the archived parent's UUID @@ -30,3 +32,30 @@ receiving, which takes the row from the parent only and gives historical workplans no edges. A direct `consistency_check.py --repo activity-core --fix` set the parent back to `archived`, and a re-run reports nothing to fix. UUIDs and tasks were untouched. Unblocks RMGR-WP-0019-T03 (see STATE-WP-0095). + +## Reject over-long blocked_on with a 422 instead of a 500 + +```task +id: STATE-WP-ADHOC-2026-10-06-T02 +status: done +priority: low +``` + +`tasks.blocked_on` is `String(200)`, but `TaskCreate`/`TaskUpdate` accepted any +length, so a longer value failed in Postgres and returned HTTP 500 (hit by Repo +Manager's C-41 sync of RMGR-WP-0019-T03). The schemas now cap it at 200 and +return a 422 that names the field; long text belongs in `blocking_reason` +(Text). Live after the next State Hub deploy. + +## Map file decision_type values onto the hub's made/pending + +```task +id: STATE-WP-ADHOC-2026-10-06-T03 +status: done +priority: low +``` + +C-32 sent a file's `decision_type` verbatim; repo-manager's RMGR-DEC-2026-004 +says `approved`, which the hub rejects with a 422, so that decision was never +indexed. `normalise_decision_type` maps resolved records and decided synonyms +to `made`, and everything else to `pending`.