ADR-164: Ticket category axis — work / warning / approval-needed / incident, orthogonal to type
Status: Proposed
Date: 2026-05-07
Related: warp#1735 (this ADR's ticket), warp#1733 (factory Actions budget tile — first concrete warning consumer), warp#1731 (factory release-status playbook link — same dashboard surface), warp#1715 (ci-runner ownership drift — should-be-warning shaped today), warp#1655 (runner-pool jam — should-have-been-incident), warp#1668 (gyrum-dana advisory contradiction — approval-needed shaped today, lives in chat), warp#1530 (auto-deploy-on-release ADR — informs incident state on factory dashboard), ADR-107 (precedent: gates as playbook primitives — same structural intuition: lift "implicit policy" to "first-class object"), ADR-126 (precedent: contract ADRs land before consumer code), ADR-115 (principle-aware reviewers — quote pinned contracts when reviewing substrate PRs)
Context
The current warp ticket model has type (bug | feature | chore | docs | refactor | security | renewal | audit | epic | principle | campaign) which answers WHAT SHAPE OF WORK. That axis treats every ticket as work-to-be-done and routes it through the same lifecycle (ready → in_progress → blocked → done).
Today's session (2026-05-07) surfaced three patterns that are NOT work-shaped, each filed as bug-typed tickets in the absence of a better representation:
Warning condition. GitHub Actions at 78% budget. Runner host drift. Queue depth > N. These are observations of a system condition; they resolve when the condition clears, not when work completes. Filed ad-hoc as
bug-typed tickets (warp#1715, warp#1655, warp#1733); the bug-shape conflates "broken thing to fix" with "active condition to track".Approval-needed. The operator said "I authorise you to do what you need to unblock" multiple times in plain chat today. The agent then ran
--skip-hard-wrap-lintoverrides on PRs #665, #550, #551, #672, #673 and considered--adminon PR #359 (declined per per-PR-authorization rule). That authorization lived only in chat — not surfaced anywhere; an agent in a fresh session 6h later would not know it had ever been given. Same shape applies to: secret rotation needed, force-push staged, destructive op pending operator OK.Incident. factory.gyrum.ai TLS broken (22:14 UTC, fixed via
docker compose up -d). warp.gyrum.ai 11h-stale deploy. ai-frontend release.yml billing-blocked. These are CURRENT broken-state conditions distinct from "work to do" (someone must work to fix, but the ticket's primary identity is "thing is broken right now").
Without a category axis: warnings clutter the bug list and can't be filtered to "active alerts only"; approval-needed lives in chat and gets lost across sessions; incidents look like normal bugs without a red-banner / page-now treatment; auto-filing jobs (budget monitor, drift detector) have nowhere clean to land.
Operator framing 2026-05-07: "If it's a ticket the warning we could have others like operator approval needed, check to authorize. Is that a new category".
Decision
Add a category axis to warp tickets, orthogonal to type. Four values, with category-specific lifecycle semantics on top of the universal status set (ready / in_progress / blocked / done / cancelled). Default value is work — every existing ticket retains today's shape.
The four categories
| category | type can be | resolves when | who acts | visual |
|---|---|---|---|---|
| work (default) | any | ticket completed (done) | any agent | normal kanban |
| warning | bug, chore | observed condition clears (auto-resolve) | nobody by default; operator ack-clearable | top-of-board strip; counts as 13 active warnings; not a kanban column |
| approval-needed | any | operator says approve / deny | operator only | dedicated kanban column + nav-bell badge |
| incident | bug | service restored AND post-mortem written | any agent + operator | red top banner; counts as 2 active incidents |
The lifecycle status set stays unchanged (ready / in_progress / blocked / done / cancelled). The SEMANTICS of done differ per category:
warning + done= "condition no longer holds" (e.g. budget back below 75%); the bot that auto-filed can also auto-resolve.approval-needed + done= "operator approved + action executed" OR "operator denied + agent stood down".incident + done= "service is healthy AND post-mortem ticket cross-linked".
Type and category are truly orthogonal. A warning + bug is "an active broken-thing condition"; a warning + chore is "an active not-broken-but-needs-attention condition" (e.g. cert expires in 7d). An approval-needed + chore is "operator must approve this housekeeping op"; an approval-needed + security is "operator must approve this rotation". The product of axes is meaningful and intentional — collapsing them into a single enum would lose information at the boundary where today's ad-hoc patterns sit.
Schema migration
Single migration on warp_items adding category text NOT NULL DEFAULT 'work' plus a CHECK constraint over the four values:
ALTER TABLE warp_items ADD COLUMN category TEXT NOT NULL DEFAULT 'work';
ALTER TABLE warp_items ADD CONSTRAINT warp_items_category_check
CHECK (category IN ('work', 'warning', 'approval-needed', 'incident'));
CREATE INDEX warp_items_category_idx ON warp_items (category);
Mirrors the migration-013 (type) shape — additive, idempotent, defaulted, indexed. Backwards-compat: every existing client that doesn't know about category continues to round-trip rows unchanged.
API surface
Read:
GET /items/{id}returns the newcategoryfield on every row.GET /items?category=warning|approval-needed|incident|workfilters by category. The filter is "explicit" in the same sense as?type=— its presence suppresses the default claim-eligibility narrowing.
Write:
POST /itemsacceptscategoryinCreateInput. Defaults to"work"when absent.PATCH /items/{id}acceptscategoryfor re-categorization. Audit trail records before/after, same shape as the migration-013typepatch path. Re-categorization is operator-explicit; auto-filing agents pick the category at creation.
CLI surface
warp-add --category <value>(and YAML frontmattercategory: <value>).warp-update --category <value>.
Both wrappers validate against the closed enum at the wire edge so a typo surfaces as 400 invalid_category rather than a Postgres CHECK violation.
Auto-file conventions (Phase 2-4 — out of scope here, named for downstream)
- Budget monitor (Phase 2 / warp#1733) fires
category: warningat 75% threshold. - Runner drift detector (Phase 2 / warp#1715-class) fires
category: warningon first 403. gyrum-complete-pr --adminwithout per-PR auth (Phase 3) firescategory: approval-neededthen exits.- Service health probes (Phase 4) fire
category: incidenton red.
Phasing
- Phase 1: schema + API + CLI for the category field. Default everything
'work'; no behavior change. (This ADR enables Phase 1.) - Phase 2: warning auto-file consumers (budget monitor, runner drift detector).
- Phase 3: approval-needed flow —
gyrum-complete-pr --admingates on operator-issued approval ticket; warp UI gains the column + bell. - Phase 4: incident flow — service health probes auto-file; red banner; cross-link to post-mortem template.
Consequences
Wins
- Three structurally-distinct patterns gain first-class representation. Today's ad-hoc bug-shaped fillers stop conflating "broken thing to fix" with "active alert to track".
- Auto-filing jobs have a clean target —
category: warningmeans "fire on threshold cross, auto-resolve when condition clears" without each consumer reinventing the resolution semantics. - Kanban + nav-bell visual treatment becomes data-driven (filter on
category=warningto render the strip; filter oncategory=incidentto render the banner) rather than tag-prefix-grep. - Approval-needed authorization stops living in chat. An operator ack on the ticket is the durable record an agent in a fresh session can read.
- Cross-session continuity improves: a fresh agent landing on a board with
2 active incidentsand13 active warningsknows the system state without spelunking chat history.
Costs
- One additional column on
warp_items. Storage cost is trivial (~10 chars per row, indexed); the index footprint is small relative to the existing per-status partial indexes. - Every API client that round-trips items must pass
categorythrough unchanged or risk dropping it on PATCH. Mitigated by the additive default — clients that don't set the field stay correct. - Four-value enum is opinionated. A future fifth pattern (e.g.
infofor non-actionable observations) requires a migration that drops + re-adds the CHECK with the wider list. The cost is one PR; the benefit is a closed enum that the validator and persona reviewers can quote at gate time.
Risks
- Over-classification. Most tickets stay
category: work. New categories should fire when there's structural reason to differentiate, not for every minor variation. The persona reviewers (per ADR-115) quote this principle when reviewing PRs that propose category changes; anapproval-neededticket without a CLEAR ACTION + REASON gets bounced. - Permanent escape hatches.
approval-neededmust not become a place where work goes to die. Phase 3's auto-block-after-N-hours rule is the structural guard; without it, a stuck approval ticket silently absorbs operator attention. - Auto-resolve flapping. A warning that auto-files at 75% and auto-resolves at 74.9% would chatter on tiny dips. Phase 2's debounce contract: 1h sliding window before auto-clear. The auto-filing agent owns the decision; the ticket's lifecycle just records it.
- Silent re-categorization. Once filed at category X, change requires explicit edit + audit row. No path through which a sweep job silently re-classifies a ticket — the audit is how operators reconstruct intent later.
Mitigations
- Validator at the wire edge (the warp API's
validCategoryfunction) plus the Postgres CHECK constraint give defense-in-depth: a typo or stale client surfaces as 400 at the API edge; a direct-SQL bypass surfaces as a CHECK violation. Both are loud failures. - Default
'work'preserves every existing client's shape. The "I'll add it later" failure mode is bounded by the one-line frontmatter / one-flag invocation needed to opt into a non-default category. - Audit row on every category change (write + PATCH) gives a queryable timeline of "when was this re-classified, by whom, why" without diffing row history manually.
Alternatives considered
Encode category in type (extend the existing enum)
Add warning, approval-needed, incident as new type values alongside bug, feature, etc. Rejected: collapses two genuinely-orthogonal axes into one. A "warning about a security vulnerability" is type:security AND category:warning — the existing enum can't represent that without exploding into a Cartesian product (security-warning, security-incident, security-approval-needed, …) that's both ugly and forces every consumer to parse compound values.
Encode category in tags (tag-prefix convention)
Use category:warning as a tag-prefix the same way epic:<slug> works today. Rejected: tags are unstructured by design; the validator can't enforce a closed enum on tag content without bolting on a parallel CHECK against the tags array (which Postgres does not natively support cleanly), and the kanban filter has to grep tag prefixes rather than read a column. Migration-013 explicitly promoted kind:* tag-prefixes to a first-class type column for these reasons; doing the same trick for category is the consistent move.
Use the status lifecycle to encode category
Add warning_active, approval_pending, incident_active as new status values. Rejected: status is the lifecycle (ready → in_progress → blocked → done → cancelled), not the identity. A warning has the same lifecycle as work — it just has different resolution semantics. Conflating identity into lifecycle would force every status-based filter (the dispatcher, the sweep, the kanban columns) to special-case the new values.
Defer until a fourth pattern shows up
Wait until the operator surfaces a fourth distinct pattern before locking the ADR. Rejected: today's session already produced three concrete instances and named four future consumers (warp#1733, warp#1715-class, the --admin gate, service health probes). The cost of locking now is one migration; the cost of deferring is N more bug-typed tickets that conflate "work" with "alert/approval/incident" and a refactor cost when the schema finally lands.
Cross-references
- Operator framing 2026-05-07: "If it's a ticket the warning we could have others like operator approval needed, check to authorize. Is that a new category".
- ADR-107 — gates as playbook primitives. Same structural intuition: lift "implicit policy" to "first-class object" so the validator and persona reviewers can quote it.
- ADR-126 — durable engineering content in git. Contract ADRs land before consumer code so persona reviewers (per ADR-115) can cite the pinned contract during review.
- warp#1735 — this ADR's ticket; carries the lifecycle table and auto-file conventions verbatim.
- warp#1733 — first concrete
warningconsumer (factory Actions budget tile). - warp#1715, warp#1655, warp#1668 — sister tickets that should be filed as
warning/incident/approval-neededonce Phase 1 lands.