OPER-214
First-class blocked-by dependency field (retire label conventions + reduce NEEDS_RESCOPE)
Suggested
Justin Cooke
Body
---
feature_id: FEAT-studio-planning-validation
---
## Context
Ticket-to-ticket dependencies today are expressed three inconsistent ways:
1. **Free-text prose** in the body — e.g. OPER-PHI-1's "Confirm OPER-113, OPER-QA-6, OPER-QA-9 flip out of PLANNING_VALIDATED on the next dispatcher tick." The dispatcher cannot read this.
2. **Labels I invented on the fly** — [REDACTED-DOB] I stamped `blocked-on:OPER-PHI-1` on five HV tickets (OPER-196, 198, 199, 201, 202) so I would remember why they were waiting. Dispatcher does not recognize the convention; it is a sticky-note.
3. **Nothing at all** — most stacked-PR chains carry the dependency only in the PR title (`PR 2/6`), not on the ticket rows. When a parent ticket goes NEEDS_RESCOPE, the children keep advancing through admission and get dispatched into a broken base.
Consequences observed in the last week:
- OPER-PHI-1 sat AWAITING_HUMAN for 4 days blocking OPER-113, OPER-QA-6, OPER-QA-9 (three PLANNING_VALIDATED tickets that could not dispatch). Dispatcher had no way to know they were blocked on it — the invariant only surfaces at dispatch time as `phi_no_reviewer`, one ticket at a time.
- The five HV tickets I healed [REDACTED-DOB] will bounce back to NEEDS_RESCOPE every dispatcher tick until OPER-PHI-1 lands, because there is no gate that says "hold this ticket in a `BLOCKED` state instead of NEEDS_RESCOPE while OPER-PHI-1 is READY_FOR_PLANNING or IN_PROGRESS." NEEDS_RESCOPE is being used as a garbage bucket for admission failures that are neither the ticket's fault nor rescope-able.
- Stacked-PR-parent-merge-detection (OPER-173, shipped) only catches the PR side. The ticket side has no equivalent.
## What
A first-class `blockedBy: string[]` field on Task rows, wired through admission, dispatcher, and the ticket UI. Three-PR stack.
### PR 1 (S / packages/database) — Schema + migration
- Add `blockedBy String[] @default([])` to `Task` model in `packages/database/prisma/schema/tasks.prisma`.
- Array of ticket identifiers (e.g. `["OPER-PHI-1", "OPER-113"]`), not FK — identifiers survive ticket renames/moves and cross-team references, FKs do not.
- Add `blocking String[]` as a virtual/derived read model materialized at read time (or a Postgres materialized view refreshed on Task write). Not stored, computed as `SELECT identifier FROM Task WHERE 'OPER-N' = ANY(blockedBy)`.
- Migration is additive, zero-downtime (default `[]`, no backfill required for legacy rows).
- Add CHECK constraint: every entry in `blockedBy` must match the `identifier` regex (`^(OPER|POR|KNUCK|OPE)-[A-Z0-9-]+$`) to prevent free-text pollution.
- Update `packages/database/src/task-repo.ts` accessors to include `blockedBy` in the default select and `blocking` in the extended select.
### PR 2 (M / apps/web) — Admission + dispatcher + planner integration
- **Admission** (`apps/web/src/lib/admission-validator.ts`): parse frontmatter `blocked_by:` YAML list into `Task.blockedBy` on ticket create + PATCH. No rejection reason — a ticket can be admissible while blocked (this is the whole point of the field; today it gets rescoped instead).
- **New Task status**: `BLOCKED_ON_TICKET`. Distinct from NEEDS_RESCOPE (which means "the ticket body is wrong"). Semantics:
- Enter: when planner tries to admit/plan a ticket and every unmet admission gate is caused by a specific other-ticket-not-done state (today: `phi_no_reviewer` when a `phi-reviewer-registration` ticket is open; future: any gate mapped to a blocker ticket).
- Exit: when every ticket in `blockedBy` is DONE. Dispatcher's next tick auto-flips BLOCKED_ON_TICKET -> READY_FOR_PLANNING.
- **Dispatcher** (`packages/dispatcher/src/tick.ts`): skip tickets in BLOCKED_ON_TICKET. Do not emit them in `dispatchAttemptsSampled`. Do not fire `queue_liveness` on their behalf.
- **Planner rejection reason mapping** (`apps/web/src/lib/planning-validator.ts`): add a `blockerTicketFor` map:
```ts
const BLOCKER_TICKET_FOR: Partial<Record<PlanningRejectionReason, (task: Task) => string | null>> = {
phi_no_reviewer: (task) => findOpenPhiReviewerRegistrationTicketFor(task.teamId),
// future: missing_verify_cmd when auto-deriver ticket (OPER-206) is open, etc.
};
```
When rejection reason has a blocker mapping AND the blocker is not DONE, transition to BLOCKED_ON_TICKET (write to `blockedBy`) instead of NEEDS_RESCOPE.
- **Auto-file blocker-transition AuditLog**: every transition into or out of BLOCKED_ON_TICKET writes an AuditLog row with the reason + blocker identifier for provenance.
### PR 3 (S / apps/web) — UI + Ledger surfacing
- Ticket detail page shows a "Blocked by" section with links + status pills. Shows "Blocking N tickets" reverse-list on the blocker.
- Task list page: BLOCKED_ON_TICKET tickets get a distinct chip (not NEEDS_RESCOPE colour) and are filterable.
- New landing chart on `/studio/operant/dashboard`: **Blocker chain depth** — how many tickets are blocked, average depth, longest chain. Surfaces the "one AWAITING_HUMAN ticket blocking five" pattern instead of hiding it in per-ticket labels.
- CLI: `pplx operant tasks list --blocked-on OPER-PHI-1` and `--blocking`.
## Acceptance Criteria
- [ ] PR 1: `blockedBy` column exists on Task with CHECK constraint on identifier format
- [ ] PR 1: `blocking` read-model returns reverse-dependency list
- [ ] PR 1: Migration is idempotent + additive (no backfill required)
- [ ] PR 2: Frontmatter `blocked_by:` list is parsed into `Task.blockedBy` on create + PATCH
- [ ] PR 2: New `BLOCKED_ON_TICKET` status distinct from NEEDS_RESCOPE
- [ ] PR 2: `phi_no_reviewer` planner rejection transitions to BLOCKED_ON_TICKET when a Studio PHI-reviewer registration ticket is open, else NEEDS_RESCOPE
- [ ] PR 2: Dispatcher skips BLOCKED_ON_TICKET, does not include them in queue_liveness invariant evidence
- [ ] PR 2: When every blocker is DONE, ticket auto-flips to READY_FOR_PLANNING on next tick
- [ ] PR 2: AuditLog rows on every BLOCKED_ON_TICKET transition (in + out) with blocker identifier
- [ ] PR 3: Ticket detail page shows Blocked by + Blocking sections
- [ ] PR 3: Task list has BLOCKED_ON_TICKET chip + filter
- [ ] PR 3: Dashboard shows blocker chain depth chart
- [ ] Regression test: file a ticket with `blocked_by: [OPER-PHI-1]` frontmatter, verify it enters BLOCKED_ON_TICKET not NEEDS_RESCOPE, verify it auto-advances when OPER-PHI-1 is marked DONE.
- [ ] Backfill script: convert existing `blocked-on:*` label conventions to `blockedBy` field values, then strip the labels. Idempotent, dry-run flag.
## Blocked-by
None. Additive schema change, additive status enum value, additive UI.
## Related work
- OPER-173 (DONE) — Stacked-PR-parent-merge-detection. This is the ticket-side counterpart.
- OPER-207 (READY_FOR_PLANNING) — Persist NEEDS_RESCOPE reason to body + sticky label. Complementary: OPER-207 makes NEEDS_RESCOPE self-explanatory, OPER-210 makes fewer things be NEEDS_RESCOPE in the first place.
- OPER-206 (IN_PROGRESS) — Auto-derive verificationCmd. Also NEEDS_RESCOPE-reduction, complementary.
- OPER-PHI-1 (READY_FOR_PLANNING, [REDACTED-DOB]) — The 4-day AWAITING_HUMAN case that surfaced this systemic gap.
## Size / Band
M / assurance (3-PR stack; each individual PR is S or M).
## Discovered by
Session 2026.09.13 dispatcher status check + HV rescope heal. Five HV tickets bouncing back to NEEDS_RESCOPE with `phi_no_reviewer` label made explicit that NEEDS_RESCOPE is being used as a garbage bucket. The heal used a `blocked-on:` label as a hand-rolled sticky-note; this ticket formalizes it.
## Verification
Ships when: (1) A `phi_in_scope: true` ticket filed while OPER-PHI-1 is open enters BLOCKED_ON_TICKET not NEEDS_RESCOPE; (2) When OPER-PHI-1 flips to DONE, all its blocked children auto-advance to READY_FOR_PLANNING on the next tick; (3) Dashboard shows current blocker chains; (4) `blocked-on:*` labels are migrated off and stripped.
Attachments
Loading attachments…
Comments
Loading comments…