Operant Studio
OPER-150

blocked-by.ts dispatch check must accept CANCELED as satisfied (align with admission)

Band
MEDIUM
Suggested
Justin Cooke

Body

feature_id: FEAT-studio-dispatcher
filing_template_version: 1

## Context

Two places in Ledger enforce `blocked_by:` contracts, and they disagree on what "satisfied" means.

**Admission-time check** (`apps/web/src/lib/ticket-admission.ts:328-345`): reads `BLOCKER_TERMINAL_STATUSES` and treats BOTH `DONE` and `CANCELED` as satisfying a blocker. The comment says: "a superseded blocker won't ship additional work and shouldn't strand its children." Error message string literally reads `"must be DONE or CANCELED"`.

**Dispatch-time check** (`packages/dispatcher/src/blocked-by.ts:86-100`, `computeUnmetBlockers`): only treats `status === "DONE"` as satisfied. A CANCELED blocker is reported as UNMET and dispatch is refused.

A ticket whose blocker was superseded (CANCELED, not DONE) will pass admission (because admission accepts CANCELED) but then hang forever in PLANNING_VALIDATED (because the dispatcher refuses to promote against a CANCELED blocker). The user has to notice, then either re-open + DONE the CANCELED blocker or hand-edit the blocked ticket's `blocked_by:` line to remove the reference. Neither is a workflow that scales with 170+ tickets/week.

Same class of bug as D-25: a contract is defined but not enforced consistently across the two places that check it. Not urgent (uncommon path — CANCELED blockers are rare) but the divergence is real and will bite the first time we CANCEL a blocker instead of finishing it.

## Acceptance Criteria

- `packages/dispatcher/src/blocked-by.ts` exports a shared constant (or reads from a shared source with ticket-admission.ts) that names the terminal-satisfying statuses. Both DONE and CANCELED are included.
- `computeUnmetBlockers` treats CANCELED as satisfied — a ticket with a CANCELED blocker is NOT reported as unmet and the dispatcher DOES promote it.
- `blocked-by.test.ts` adds coverage: (a) blocker status DONE -> promotable; (b) blocker status CANCELED -> promotable; (c) blocker status IN_PROGRESS -> unmet; (d) blocker status BACKLOG -> unmet; (e) mixed DONE+CANCELED blockers -> promotable; (f) mixed CANCELED+IN_PROGRESS -> unmet (the IN_PROGRESS one blocks).
- `refusalComment` copy (`backend.ts:293-296`) updated so the refusal message reads `"must be DONE or CANCELED"` matching admission's wording — currently reads `"declared blocker(s) are not DONE"` which will now be misleading.
- Retroactive audit query: find any tickets currently in PLANNING_VALIDATED with a `blocked_by:` reference to a CANCELED ticket in the same team. If any exist, attach the list as a Comment on this ticket for manual review after the fix ships.

## Non-goals

- Broader alignment work between admission and dispatch (that's the D-25 helper). This ticket is the narrow CANCELED fix.
- Changing what `CANCELED` means for downstream ticket states (still terminal, still not restartable).

## Related

- Sibling of the D-25 filing-template-version work (OPER-147) — same contract-drift class.
- Independent, no `blocked_by:` — can dispatch immediately.

Attachments

Loading attachments…

Comments

Loading comments…