Vikunja #137 — Four-guard state model spec (terminal/done, blocked/unblock, running/watcher-owner, done/no-rewatch) #5

Closed
opened 2026-09-14 23:20:41 +00:00 by Mark · 2 comments
Owner

Tracking issue for the four-guard state model spec being implemented on Mark/kanban (plan §1 / Vikunja #137). Closes when all four rules are enforced in code with explicit guards and the four named integration tests pass.

Four rules

  1. done is terminal — any transition into a done state (validated, archived, failed) sets terminal=true on the card; transitions out of done are rejected at the guard layer regardless of source.
  2. blocked requires kanban_unblock — blocked cards only return to active via an explicit kanban_unblock event; nothing else flips them back.
  3. running owned by watcher — running is owned exclusively by the watcher from the moment it's set until it reaches a terminal state; no other actor may transition a running card.
  4. done cards never re-watched — the watcher drops done cards on entry and never re-subscribes them.

Implementation status (on Mark/kanban)

All four rules are enforced in code with explicit guards on branch 137-kanban-guards-and-watcher-branches @ a0bc081d9a53d77d9a1e294ab275ef64864c377f (also merged to main as commit 4b983dc via PR #2 on 2026-09-12). 157/157 vitest tests pass locally on the branch HEAD.

Rule-to-guard mapping

# Code guard Test
1 server/transition_guards.ts::computeFlagUpdatesForTransition (line 91: terminal: 1 on done entry; line 132: reject done→X with kind: done_is_terminal) + server/storage.ts::transitionTask (line 290: atomic flag update) (a) four_guards_integration.test.ts:142 — 2 tests
2 server/watcher.ts::branch_blocked (line 150: unsubscribe, only re-add on kanban_unblock event at line 287) + server/storage.ts::transitionTask (line 236: rejects blocked→active with typed error referencing kanbanUnblock) + server/storage.ts::kanbanUnblock (line 55: sole handler for blocked→assigned) (b) four_guards_integration.test.ts:251 — 3 tests
3 server/transition_guards.ts::assertCallerAllowedForRunning (rejects non-watcher with kind: foreign_running_write) + server/watcher.ts::branch_running (line 170: tick-entry assertion card.owner !== WATCHER_PRINCIPAL) + server/principal.ts::deriveCallerFromRequest (HTTP trust boundary, body-supplied caller ignored) (c) four_guards_integration.test.ts:382 — 5 tests
4 server/watcher.ts::branch_done (line 131: drop_done action; _watchSet mutation only at line 181 [running dispatch] and line 287 [kanban_unblock arm]) + KANBAN_EVENT_CATALOG exposes no event that re-adds a done card (d) four_guards_integration.test.ts:552 — 3 tests

Diff stats vs. origin/main

13 files changed, 3612 insertions(+), 22 deletions(-).

  • shared/schema.ts — terminal + owner columns on tasks
  • migrations/0001_add_terminal_owner.sql — schema migration with safe defaults
  • server/transition_guards.ts (new) — pure-logic guard module with typed TransitionGuardError{kind:…}
  • server/principal.ts (new) — server-derived caller trust boundary
  • server/storage.ts — transitionTask runs guard before ALLOWED_TRANSITIONS; kanbanUnblock() storage method; setTaskFlags()
  • server/routes.ts — X-Internal-Actor header → caller derivation; PATCH /flags route; 409 surface
  • server/watcher.ts (new) — in-process Watcher class with Sweep() three-branch logic + deliverKanbanEvent() catalog
  • server/__tests__/four_guards_integration.test.ts (new) — 17 tests across 5 describe blocks covering (a)(b)(c)(d) plus (e) owner-assertion
  • 4 supporting test files (transition_guards: 23, principal: 16, routes: 21, storage: 35) — all green

PRs

  • #2 — merged (the actual work landed on main as commit 4b983dc)
  • #4 — opened with the proper title + rule-to-guard mapping; no diff (branch tip is an ancestor of main)

Acceptance

  • All four rules enforced in code with explicit guards (not just convention)
  • kanban_unblock is the only path out of blocked
  • Watcher drop-list for done verified by test
  • PR opened referencing #137 with rule-to-guard mapping
  • CI green on the merge commit (4b983dc) — run #6623 success, 93s
  • Four named integration tests exist and pass

Refs: kanban t_d3f147f9 (root), t_e49638bb (implementer), t_002084ad/t_c657a9cc/t_383734ad/t_fc944a5a (children), t_7fd87e14 (verifier).

Tracking issue for the four-guard state model spec being implemented on Mark/kanban (plan §1 / Vikunja #137). Closes when all four rules are enforced in code with explicit guards and the four named integration tests pass. ## Four rules 1. **done is terminal** — any transition into a `done` state (validated, archived, failed) sets `terminal=true` on the card; transitions out of `done` are rejected at the guard layer regardless of source. 2. **blocked requires kanban_unblock** — `blocked` cards only return to active via an explicit `kanban_unblock` event; nothing else flips them back. 3. **running owned by watcher** — `running` is owned exclusively by the watcher from the moment it's set until it reaches a terminal state; no other actor may transition a running card. 4. **done cards never re-watched** — the watcher drops `done` cards on entry and never re-subscribes them. ## Implementation status (on Mark/kanban) All four rules are enforced in code with explicit guards on branch `137-kanban-guards-and-watcher-branches` @ `a0bc081d9a53d77d9a1e294ab275ef64864c377f` (also merged to `main` as commit `4b983dc` via PR #2 on 2026-09-12). 157/157 vitest tests pass locally on the branch HEAD. ### Rule-to-guard mapping | # | Code guard | Test | |---|---|---| | 1 | `server/transition_guards.ts::computeFlagUpdatesForTransition` (line 91: `terminal: 1` on done entry; line 132: reject done→X with `kind: done_is_terminal`) + `server/storage.ts::transitionTask` (line 290: atomic flag update) | (a) `four_guards_integration.test.ts:142` — 2 tests | | 2 | `server/watcher.ts::branch_blocked` (line 150: unsubscribe, only re-add on `kanban_unblock` event at line 287) + `server/storage.ts::transitionTask` (line 236: rejects blocked→active with typed error referencing `kanbanUnblock`) + `server/storage.ts::kanbanUnblock` (line 55: sole handler for blocked→assigned) | (b) `four_guards_integration.test.ts:251` — 3 tests | | 3 | `server/transition_guards.ts::assertCallerAllowedForRunning` (rejects non-watcher with `kind: foreign_running_write`) + `server/watcher.ts::branch_running` (line 170: tick-entry assertion `card.owner !== WATCHER_PRINCIPAL`) + `server/principal.ts::deriveCallerFromRequest` (HTTP trust boundary, body-supplied caller ignored) | (c) `four_guards_integration.test.ts:382` — 5 tests | | 4 | `server/watcher.ts::branch_done` (line 131: `drop_done` action; `_watchSet` mutation only at line 181 [running dispatch] and line 287 [`kanban_unblock` arm]) + `KANBAN_EVENT_CATALOG` exposes no event that re-adds a done card | (d) `four_guards_integration.test.ts:552` — 3 tests | ### Diff stats vs. origin/main 13 files changed, 3612 insertions(+), 22 deletions(-). - `shared/schema.ts` — `terminal` + `owner` columns on `tasks` - `migrations/0001_add_terminal_owner.sql` — schema migration with safe defaults - `server/transition_guards.ts` (new) — pure-logic guard module with typed `TransitionGuardError{kind:…}` - `server/principal.ts` (new) — server-derived caller trust boundary - `server/storage.ts` — `transitionTask` runs guard before ALLOWED_TRANSITIONS; `kanbanUnblock()` storage method; `setTaskFlags()` - `server/routes.ts` — `X-Internal-Actor` header → caller derivation; PATCH `/flags` route; 409 surface - `server/watcher.ts` (new) — in-process `Watcher` class with `Sweep()` three-branch logic + `deliverKanbanEvent()` catalog - `server/__tests__/four_guards_integration.test.ts` (new) — 17 tests across 5 describe blocks covering (a)(b)(c)(d) plus (e) owner-assertion - 4 supporting test files (transition_guards: 23, principal: 16, routes: 21, storage: 35) — all green ## PRs - #2 — merged (the actual work landed on main as commit `4b983dc`) - #4 — opened with the proper title + rule-to-guard mapping; no diff (branch tip is an ancestor of main) ## Acceptance - [x] All four rules enforced in code with explicit guards (not just convention) - [x] `kanban_unblock` is the only path out of `blocked` - [x] Watcher drop-list for `done` verified by test - [x] PR opened referencing #137 with rule-to-guard mapping - [x] CI green on the merge commit (`4b983dc`) — run #6623 success, 93s - [x] Four named integration tests exist and pass Refs: kanban t_d3f147f9 (root), t_e49638bb (implementer), t_002084ad/t_c657a9cc/t_383734ad/t_fc944a5a (children), t_7fd87e14 (verifier).
Author
Owner

PR #4 opened with the proper title + rule-to-guard mapping:

PR: #4

Note: PR #2 was the merge PR (auto-generated title "Merge 137-kanban-guards-and-watcher-branches -> main"), already merged to main on 2026-09-12 16:51:50 UTC as commit 4b983dc. PR #4 captures the rule-to-guard mapping table that PR #2's auto-description lacked.

Rule-to-guard summary (full table in PR #4 description)

# Rule Code guard Test
1 done is terminal; no transition out of done server/transition_guards.ts::computeFlagUpdatesForTransition sets terminal: 1 on done entry; line 132 rejects done→X with kind: done_is_terminal. Wired into server/storage.ts::transitionTask (line 290, atomic flag update). 409 surface at server/routes.ts. (a) four_guards_integration.test.ts:142 — 2 tests
2 blocked only returns to active via kanban_unblock server/watcher.ts::branch_blocked (line 150, unsubscribe; re-add only on kanban_unblock event in deliverKanbanEvent line 287) + server/storage.ts::transitionTask (line 236, rejects blocked→active with typed error referencing kanbanUnblock) + server/storage.ts::kanbanUnblock (line 55, sole handler) (b) four_guards_integration.test.ts:251 — 3 tests
3 running is owned exclusively by the watcher server/transition_guards.ts::assertCallerAllowedForRunning (rejects non-watcher with kind: foreign_running_write, missing/null caller with kind: missing_caller_for_running) + server/watcher.ts::branch_running (line 170, tick-entry assertion card.owner !== WATCHER_PRINCIPAL throws WatcherError{kind: foreign_running_write}) + server/principal.ts::deriveCallerFromRequest (HTTP trust boundary, body-supplied caller ignored, server-derived from X-Internal-Actor header) (c) four_guards_integration.test.ts:382 — 5 tests
4 Done cards never re-watched server/watcher.ts::branch_done (line 131, drop_done action; _watchSet.add only at line 181 [legit running dispatch] and line 287 [kanban_unblock arm]) + KANBAN_EVENT_CATALOG exposes no event that re-adds a done card (d) four_guards_integration.test.ts:552 — 3 tests

CI status

CI on the merge commit (4b983dc) is GREEN — run #6623, status=success, 93 seconds duration, triggered by push to main on 2026-09-12 17:55:51Z → 2026-09-12 17:57:24Z. URL: https://forgejo.tail018ac4.ts.net/Mark/kanban/actions/runs/2.

The PR #4 head (a0bc081) is an ancestor of the merge commit (4b983dc) and contains the same code that CI verified — 157/157 vitest pass locally on the branch HEAD.

Acceptance

  • All four rules enforced in code with explicit guards (not just convention) ✓
  • kanban_unblock is the only path out of blocked ✓
  • Watcher drop-list for done verified by test (5 sweeps, no re-subscribe) ✓
  • PR opened referencing #137 with rule-to-guard mapping table ✓
  • CI green on the merged code ✓
  • Four named integration tests exist and pass ✓

Refs: kanban t_d3f147f9 (root), t_e49638bb (implementer), t_002084ad/t_c657a9cc/t_383734ad/t_fc944a5a (children), t_7fd87e14 (verifier).

PR #4 opened with the proper title + rule-to-guard mapping: **PR**: https://forgejo.tail018ac4.ts.net/Mark/kanban/pulls/4 Note: PR #2 was the merge PR (auto-generated title "Merge 137-kanban-guards-and-watcher-branches -> main"), already merged to main on 2026-09-12 16:51:50 UTC as commit `4b983dc`. PR #4 captures the rule-to-guard mapping table that PR #2's auto-description lacked. ## Rule-to-guard summary (full table in PR #4 description) | # | Rule | Code guard | Test | |---|---|---|---| | 1 | `done` is terminal; no transition out of `done` | `server/transition_guards.ts::computeFlagUpdatesForTransition` sets `terminal: 1` on done entry; line 132 rejects done→X with `kind: done_is_terminal`. Wired into `server/storage.ts::transitionTask` (line 290, atomic flag update). 409 surface at `server/routes.ts`. | (a) `four_guards_integration.test.ts:142` — 2 tests | | 2 | `blocked` only returns to active via `kanban_unblock` | `server/watcher.ts::branch_blocked` (line 150, unsubscribe; re-add only on `kanban_unblock` event in `deliverKanbanEvent` line 287) + `server/storage.ts::transitionTask` (line 236, rejects blocked→active with typed error referencing `kanbanUnblock`) + `server/storage.ts::kanbanUnblock` (line 55, sole handler) | (b) `four_guards_integration.test.ts:251` — 3 tests | | 3 | `running` is owned exclusively by the watcher | `server/transition_guards.ts::assertCallerAllowedForRunning` (rejects non-watcher with `kind: foreign_running_write`, missing/null caller with `kind: missing_caller_for_running`) + `server/watcher.ts::branch_running` (line 170, tick-entry assertion `card.owner !== WATCHER_PRINCIPAL` throws `WatcherError{kind: foreign_running_write}`) + `server/principal.ts::deriveCallerFromRequest` (HTTP trust boundary, body-supplied caller ignored, server-derived from `X-Internal-Actor` header) | (c) `four_guards_integration.test.ts:382` — 5 tests | | 4 | Done cards never re-watched | `server/watcher.ts::branch_done` (line 131, `drop_done` action; `_watchSet.add` only at line 181 [legit running dispatch] and line 287 [`kanban_unblock` arm]) + `KANBAN_EVENT_CATALOG` exposes no event that re-adds a done card | (d) `four_guards_integration.test.ts:552` — 3 tests | ## CI status CI on the merge commit (`4b983dc`) is GREEN — run #6623, status=success, 93 seconds duration, triggered by push to main on 2026-09-12 17:55:51Z → 2026-09-12 17:57:24Z. URL: https://forgejo.tail018ac4.ts.net/Mark/kanban/actions/runs/2. The PR #4 head (`a0bc081`) is an ancestor of the merge commit (`4b983dc`) and contains the same code that CI verified — 157/157 vitest pass locally on the branch HEAD. ## Acceptance - All four rules enforced in code with explicit guards (not just convention) ✓ - `kanban_unblock` is the only path out of `blocked` ✓ - Watcher drop-list for `done` verified by test (5 sweeps, no re-subscribe) ✓ - PR opened referencing #137 with rule-to-guard mapping table ✓ - CI green on the merged code ✓ - Four named integration tests exist and pass ✓ Refs: kanban t_d3f147f9 (root), t_e49638bb (implementer), t_002084ad/t_c657a9cc/t_383734ad/t_fc944a5a (children), t_7fd87e14 (verifier).
Author
Owner

Closing — tracking issue for the four-guard state model spec (Vikunja #137). Implementation tracked in Vikunja / kanban card body.

Closing — tracking issue for the four-guard state model spec (Vikunja #137). Implementation tracked in Vikunja / kanban card body.
Mark closed this issue 2026-09-15 15:36:54 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Mark/kanban#5
No description provided.