diff --git a/api/services/reconciliation.py b/api/services/reconciliation.py new file mode 100644 index 0000000..a75eb0f --- /dev/null +++ b/api/services/reconciliation.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from api.services.lifecycle import status_value +from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status + + +class ReconciliationClass(str, Enum): + WRITE_THROUGH = "write_through" + DEFERRED = "deferred" + HUMAN_CONFIRMATION = "human_confirmation" + + +@dataclass(frozen=True) +class StateChangeClassification: + reconciliation_class: ReconciliationClass + reason: str + follow_up: str + + +WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"} +TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} + + +def classify_workstream_status_change( + *, + current_status: Any, + target_status: Any, + file_backed: bool, + archived_file: bool = False, + tasks_terminal: bool | None = None, +) -> StateChangeClassification: + """Classify a UI-originated workstream status transition.""" + current = normalize_workstream_status(current_status) + target = normalize_workstream_status(target_status) + + if not file_backed: + return StateChangeClassification( + ReconciliationClass.DEFERRED, + "workstream is not backed by a local workplan file", + "create a reconciliation record for operator follow-up", + ) + if archived_file: + return StateChangeClassification( + ReconciliationClass.HUMAN_CONFIRMATION, + "workplan file is archived", + "confirm whether to restore the file or leave the DB state unchanged", + ) + if current == target: + return StateChangeClassification( + ReconciliationClass.WRITE_THROUGH, + "status is unchanged", + "no file update required", + ) + if target in WRITE_THROUGH_WORKSTREAM_STATUSES and current not in CLOSED_WORKSTREAM_STATUSES: + return StateChangeClassification( + ReconciliationClass.WRITE_THROUGH, + "open lifecycle transition can be represented in workplan frontmatter", + "patch workplan status and sync the DB from file", + ) + if target == "finished": + if tasks_terminal is True: + return StateChangeClassification( + ReconciliationClass.WRITE_THROUGH, + "all known tasks are terminal", + "patch workplan status to finished and sync the DB from file", + ) + return StateChangeClassification( + ReconciliationClass.HUMAN_CONFIRMATION, + "finishing with non-terminal or unknown task state can hide open work", + "ask for confirmation or finish/cancel remaining tasks first", + ) + if target == "archived": + return StateChangeClassification( + ReconciliationClass.HUMAN_CONFIRMATION, + "archiving may move files and hide active context", + "ask for confirmation and run the archive workflow", + ) + if target == "blocked": + return StateChangeClassification( + ReconciliationClass.HUMAN_CONFIRMATION, + "blocked workstreams need an explicit blocker or dependency reason", + "capture the blocker before writing status", + ) + return StateChangeClassification( + ReconciliationClass.DEFERRED, + f"unsupported workstream status transition {current!r} -> {target!r}", + "create a reconciliation record for operator review", + ) + + +def classify_task_status_change( + *, + current_status: Any, + target_status: Any, + file_backed: bool, + task_linked: bool, + archived_file: bool = False, + blocking_reason: str | None = None, +) -> StateChangeClassification: + """Classify a UI-originated task status transition.""" + current = status_value(current_status) + target = status_value(target_status) + + if not file_backed: + return StateChangeClassification( + ReconciliationClass.DEFERRED, + "task belongs to a workstream without a local workplan file", + "create a reconciliation record for operator follow-up", + ) + if archived_file: + return StateChangeClassification( + ReconciliationClass.HUMAN_CONFIRMATION, + "task belongs to an archived workplan file", + "confirm whether to restore or edit the archived file", + ) + if not task_linked: + return StateChangeClassification( + ReconciliationClass.DEFERRED, + "task is not linked to a task block in the workplan file", + "create or repair the task block link before writing status", + ) + if current == target: + return StateChangeClassification( + ReconciliationClass.WRITE_THROUGH, + "status is unchanged", + "no file update required", + ) + if target == "blocked" and not (blocking_reason or "").strip(): + return StateChangeClassification( + ReconciliationClass.HUMAN_CONFIRMATION, + "blocked tasks require a blocking reason", + "capture the blocker before writing status", + ) + if target in TASK_STATUSES: + return StateChangeClassification( + ReconciliationClass.WRITE_THROUGH, + "task status can be represented in the workplan task block", + "patch task block status and sync the DB from file", + ) + return StateChangeClassification( + ReconciliationClass.DEFERRED, + f"unsupported task status transition {current!r} -> {target!r}", + "create a reconciliation record for operator review", + ) diff --git a/tests/test_reconciliation.py b/tests/test_reconciliation.py new file mode 100644 index 0000000..e23897d --- /dev/null +++ b/tests/test_reconciliation.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from api.services.reconciliation import ( + ReconciliationClass, + classify_task_status_change, + classify_workstream_status_change, +) + + +def test_workstream_open_transition_writes_through(): + result = classify_workstream_status_change( + current_status="active", + target_status="backlog", + file_backed=True, + ) + + assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH + assert "frontmatter" in result.reason + + +def test_workstream_unfiled_transition_is_deferred(): + result = classify_workstream_status_change( + current_status="active", + target_status="backlog", + file_backed=False, + ) + + assert result.reconciliation_class == ReconciliationClass.DEFERRED + assert "not backed" in result.reason + + +def test_workstream_finish_requires_terminal_tasks(): + result = classify_workstream_status_change( + current_status="active", + target_status="finished", + file_backed=True, + tasks_terminal=False, + ) + + assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION + assert "open work" in result.reason + + +def test_workstream_finish_with_terminal_tasks_writes_through(): + result = classify_workstream_status_change( + current_status="active", + target_status="finished", + file_backed=True, + tasks_terminal=True, + ) + + assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH + assert "finished" in result.follow_up + + +def test_archived_workstream_file_requires_confirmation(): + result = classify_workstream_status_change( + current_status="finished", + target_status="active", + file_backed=True, + archived_file=True, + ) + + assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION + assert "archived" in result.reason + + +def test_task_status_update_writes_through_to_task_block(): + result = classify_task_status_change( + current_status="todo", + target_status="in_progress", + file_backed=True, + task_linked=True, + ) + + assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH + assert "task block" in result.reason + + +def test_task_without_file_is_deferred(): + result = classify_task_status_change( + current_status="todo", + target_status="in_progress", + file_backed=False, + task_linked=True, + ) + + assert result.reconciliation_class == ReconciliationClass.DEFERRED + assert "without a local workplan file" in result.reason + + +def test_unlinked_task_is_deferred_until_link_repaired(): + result = classify_task_status_change( + current_status="todo", + target_status="in_progress", + file_backed=True, + task_linked=False, + ) + + assert result.reconciliation_class == ReconciliationClass.DEFERRED + assert "not linked" in result.reason + + +def test_blocked_task_requires_blocking_reason(): + result = classify_task_status_change( + current_status="todo", + target_status="blocked", + file_backed=True, + task_linked=True, + ) + + assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION + assert "blocking reason" in result.reason + + +def test_blocked_task_with_reason_writes_through(): + result = classify_task_status_change( + current_status="todo", + target_status="blocked", + file_backed=True, + task_linked=True, + blocking_reason="Waiting on dependency", + ) + + assert result.reconciliation_class == ReconciliationClass.WRITE_THROUGH diff --git a/workplans/STATE-WP-0048-ui-state-change-reconciliation.md b/workplans/STATE-WP-0048-ui-state-change-reconciliation.md index 2b5dce0..f76bb32 100644 --- a/workplans/STATE-WP-0048-ui-state-change-reconciliation.md +++ b/workplans/STATE-WP-0048-ui-state-change-reconciliation.md @@ -4,7 +4,7 @@ type: workplan title: "UI State Change Reconciliation" domain: custodian repo: state-hub -status: proposed +status: active owner: codex topic_slug: custodian planning_priority: high @@ -33,11 +33,26 @@ for other workstream and task state transitions. This workplan defines the reconciliation path between UI actions, DB state, repo files, and scheduled cleanup. +## Reconciliation Classes + +| Class | Meaning | Default follow-up | +|-------|---------|-------------------| +| `write_through` | The change can be represented directly in the workplan file. | Patch the file first, then sync the DB from file state. | +| `deferred` | The change cannot safely touch the file immediately. | Create a visible reconciliation record or task. | +| `human_confirmation` | The change may hide work, require a blocker reason, or move archived context. | Ask for confirmation or gather missing intent before writing. | + +## Initial Transition Classification + +| Target | Safe write-through | Deferred | Human confirmation | +|--------|--------------------|----------|--------------------| +| Workstream status | Open, file-backed transitions among `proposed`, `ready`, `active`, and `backlog`; `finished` only when known tasks are terminal. | Non-file-backed workstreams, unsupported statuses, unavailable file links. | Archived files, `blocked` without explicit blocker/dependency context, `finished` with open/unknown tasks, `archived`. | +| Task status | Linked task blocks in active workplan files for `todo`, `in_progress`, `done`, `cancelled`, and `blocked` with a blocking reason. | Non-file-backed workstreams or DB tasks without a linked task block. | Archived files and `blocked` task changes without a blocking reason. | + ## T01 - Classify UI State Changes ```task id: STATE-WP-0048-T01 -status: todo +status: done priority: high state_hub_task_id: "0332be96-ebc7-4a7b-97c6-bbe6ae3a66ac" ``` @@ -49,6 +64,10 @@ confirmation. Done when each workstream/task transition has a reconciliation class and a reason. +Result 2026-05-23: added the initial classification matrix and +`api.services.reconciliation` helpers for workstream/task status changes. Tests +pin the write-through, deferred, and human-confirmation decisions. + ## T02 - Add Reconciliation API Contract ```task