Classify UI state reconciliation changes

This commit is contained in:
2026-05-23 17:14:41 +02:00
parent 87d49a2573
commit 215a91e599
3 changed files with 294 additions and 2 deletions

View File

@@ -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",
)

View File

@@ -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

View File

@@ -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