Drop the proposed state: boards hold only accepted work

Plan proposals live in the conversation (plan-approval flow); items are created open/unassigned and work starts at assignment — the granted, revocable authority.
Also closes a verify gap: tail truncation is now caught against the stored head hash.
This commit is contained in:
Rohit C Prasad
2026-08-16 08:07:10 -07:00
committed by Rohit P
parent d8e4fc73c0
commit fc24b667ed
15 changed files with 106 additions and 247 deletions
+15 -21
View File
@@ -21,7 +21,6 @@ def store(tmp_path):
def assigned_item(store, assignee="worker-1"):
item = store.create_item(SPACE, LEAD, title="Task", criteria="tests pass")
store.transition(SPACE, USER, item["id"], "approved")
store.assign(SPACE, LEAD, item["id"], assignee)
return item["id"]
@@ -33,19 +32,21 @@ def test_acceptance_criteria_are_required(store):
store.create_item(SPACE, LEAD, title="Vague hope", criteria=" ")
def test_workers_file_items_into_the_proposed_gate(store):
def test_workers_file_items_open_and_unassigned(store):
mine = assigned_item(store)
filed = store.create_item(
SPACE, WORKER, title="Rounding bug in invoices", criteria="repro + fix",
parent=mine,
)
assert filed["state"] == "proposed"
assert filed["state"] == "open"
assert filed["assignee"] == ""
assert filed["creator"] == "worker-1"
# the worker sees its own filing; nothing runs until the user approves it
# the worker sees its own filing; nothing runs until the lead/user assigns it,
# and the filer can't assign it to itself
visible = {item["id"] for item in store.list_items(SPACE, WORKER)}
assert filed["id"] in visible
with pytest.raises(AuthorityError, match="decomposition gate"):
store.transition(SPACE, WORKER, filed["id"], "approved")
with pytest.raises(AuthorityError):
store.assign(SPACE, WORKER, filed["id"], "worker-1")
other_worker = {item["id"] for item in store.list_items(SPACE, OTHER)}
assert filed["id"] not in other_worker
@@ -76,15 +77,7 @@ def test_illegal_edges_rejected(store):
with pytest.raises(BoardError, match="illegal transition"):
store.transition(SPACE, USER, item["id"], "done")
with pytest.raises(BoardError, match="illegal transition"):
store.transition(SPACE, USER, item["id"], "in_progress")
def test_only_the_user_approves(store):
item = store.create_item(SPACE, LEAD, title="T", criteria="c")
with pytest.raises(AuthorityError, match="decomposition gate"):
store.transition(SPACE, LEAD, item["id"], "approved")
approved = store.transition(SPACE, USER, item["id"], "approved")
assert approved["state"] == "approved"
store.transition(SPACE, USER, item["id"], "review")
def test_workers_never_mark_done(store):
@@ -110,8 +103,8 @@ def test_worker_cannot_cancel(store):
def test_cancel_is_a_board_verb_and_reopen_works(store):
item_id = assigned_item(store)
store.transition(SPACE, LEAD, item_id, "canceled")
reopened = store.transition(SPACE, LEAD, item_id, "approved")
assert reopened["state"] == "approved"
reopened = store.transition(SPACE, LEAD, item_id, "open")
assert reopened["state"] == "open"
assert reopened["assignee"] == "worker-1" # reassignable — assignment survives
@@ -136,10 +129,11 @@ def test_rework_loop(store):
# ---------------------------------------------------------------- assign / link
def test_cannot_assign_before_approval(store):
item = store.create_item(SPACE, LEAD, title="T", criteria="c")
with pytest.raises(BoardError, match="after approval"):
store.assign(SPACE, LEAD, item["id"], "worker-1")
def test_cannot_assign_closed_items(store):
item_id = assigned_item(store)
store.transition(SPACE, LEAD, item_id, "canceled")
with pytest.raises(BoardError, match="reopen"):
store.assign(SPACE, LEAD, item_id, "worker-2")
def test_workers_cannot_assign_or_link(store):
-1
View File
@@ -38,7 +38,6 @@ def board(tmp_path, journal):
def case_item(board, case="findings", assignee="worker-1", space=SPACE):
item = board.create_item(space, LEAD, title="Task", criteria="c", case=case)
board.transition(space, USER, item["id"], "approved")
board.assign(space, LEAD, item["id"], assignee)
return item["id"]
+3 -5
View File
@@ -18,17 +18,15 @@ def store(tmp_path):
def seed(store, space="proj"):
item = store.create_item(
return store.create_item(
space, LEAD, title="Review api", criteria="every route triaged"
)
store.transition(space, USER, item["id"], "approved")
return item
def test_events_hash_chain_verifies(store):
seed(store)
store.create_item("proj", LEAD, title="Second", criteria="done means done")
assert store.verify_chain("proj") == 3
assert store.verify_chain("proj") == 2
def test_out_of_band_edit_breaks_the_chain(store):
@@ -61,7 +59,7 @@ def test_chains_are_per_space(store):
conn.execute("UPDATE team_events SET taint = 1 WHERE space = 'beta'")
conn.commit()
conn.close()
assert store.verify_chain("alpha") == 2 # untouched space still verifies
assert store.verify_chain("alpha") == 1 # untouched space still verifies
with pytest.raises(ChainError):
store.verify_chain("beta")