OPER-151
Admission gate must run on PATCH, not only POST (contract-drift close-out)
Band
MEDIUM
Suggested
Justin Cooke
Body
feature_id: FEAT-studio-planning-validation
filing_template_version: 1
## Context
The admission gate (`apps/web/src/lib/ticket-admission.ts`) is the enforcement point for feature-map membership, PHI-band reviewer requirements, dependency-map edge consistency, and `blocked_by:` prose (only-DONE-or-CANCELED-blockers-are-satisfied).
It is called from POST (`apps/web/src/app/api/studio/[team]/tasks/route.ts:164` — `await assertAdmissible(...)`) but NOT from PATCH (`apps/web/src/app/api/studio/[team]/tasks/[id]/route.ts:49-*` — no admission call).
Concrete evidence of the gap (this session, 2026.09.09):
1. Filed OPER-147 and OPER-149 via POST with `blocked_by:` placeholders (literal `<ticket for D-25 next action 1>`).
2. Bodies were later PATCHed to insert real blocker identifiers (OPER-146 and OPER-148) at a time when those blockers were still in BACKLOG (not DONE, not CANCELED).
3. Admission would have rejected the PATCH with `blocked_by_not_done` if it ran. It didn't. Both tickets accepted the PATCH and jumped to PLANNING_VALIDATED. The dispatcher's separate `blocked_by:` check (blocked-by.ts) held them from actually running, which is what saved us -- but that safety belt is defense in depth, not the primary check.
Same contract-drift class as D-25 (admission-fields helper) and the CANCELED-treatment inconsistency: an enforcement contract exists but is only enforced at a subset of the mutation points. Any field admission cares about (feature_id, phi band, blocked_by prose, body length gates) can be inserted post-hoc via PATCH and never see the gate.
## Acceptance Criteria
- `apps/web/src/app/api/studio/[team]/tasks/[id]/route.ts` PATCH handler calls `assertAdmissible` whenever any admission-relevant field is being mutated. The minimum admission-relevant field set is: `body`, `phiBand`, `phiReviewerId`, `featureId` (if surfaced as top-level; otherwise change to body's `feature_id:` line implies re-check). If admission rejects, the PATCH returns 4xx with the same shape as POST's rejection response (`{error, code, reason, details}`) and does NOT write the change.
- Non-admission-relevant PATCHes (e.g., changing `priority`, `title`, `assignee`, adding a comment) do NOT trigger admission — those are unchanged.
- Test coverage: (a) PATCH body inserting a `blocked_by:` ref to a BACKLOG ticket is rejected; (b) PATCH body inserting a `blocked_by:` ref to a DONE ticket is accepted; (c) PATCH body inserting a `blocked_by:` ref to a CANCELED ticket is accepted (matches admission contract); (d) PATCH priority change does not call admission; (e) PATCH title change does not call admission; (f) PATCH removing all admission-relevant frontmatter still calls admission and passes if the resulting body still admits.
- Retroactive audit: query tasks where `updatedAt > createdAt + 5s AND status IN (PLANNING_VALIDATED, IN_PROGRESS, DONE)` and diff body between latest AuditLog snapshot and creation. Attach any tickets whose PATCH inserted a `feature_id:` or `blocked_by:` that would fail current admission as a Comment on this ticket for manual review.
## Non-goals
- Running admission on comment writes, attachment writes, or review verdict writes -- those endpoints don't mutate admission-relevant fields.
- Retroactive re-admission of already-DONE tickets. (What's done is done.)
## Related
- Sibling of OPER-147 (D-25 template-version helper) and the CANCELED-treatment ticket -- three tickets closing out the admission-contract-drift class.
- Independent, no `blocked_by:` -- can dispatch immediately.Attachments
Loading attachments…
Comments
Loading comments…