Decisions

ADR-164: Ticket category axis — work / warning / approval-needed / incident, orthogonal to type

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…

#164

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:

  1. 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".

  2. 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-lint overrides on PRs #665, #550, #551, #672, #673 and considered --admin on 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.

  3. 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 new category field on every row.
  • GET /items?category=warning|approval-needed|incident|work filters by category. The filter is "explicit" in the same sense as ?type= — its presence suppresses the default claim-eligibility narrowing.

Write:

  • POST /items accepts category in CreateInput. Defaults to "work" when absent.
  • PATCH /items/{id} accepts category for re-categorization. Audit trail records before/after, same shape as the migration-013 type patch path. Re-categorization is operator-explicit; auto-filing agents pick the category at creation.

CLI surface

  • warp-add --category <value> (and YAML frontmatter category: <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: warning at 75% threshold.
  • Runner drift detector (Phase 2 / warp#1715-class) fires category: warning on first 403.
  • gyrum-complete-pr --admin without per-PR auth (Phase 3) fires category: approval-needed then exits.
  • Service health probes (Phase 4) fire category: incident on red.

Phasing

  1. Phase 1: schema + API + CLI for the category field. Default everything 'work'; no behavior change. (This ADR enables Phase 1.)
  2. Phase 2: warning auto-file consumers (budget monitor, runner drift detector).
  3. Phase 3: approval-needed flow — gyrum-complete-pr --admin gates on operator-issued approval ticket; warp UI gains the column + bell.
  4. 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: warning means "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=warning to render the strip; filter on category=incident to 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 incidents and 13 active warnings knows 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 category through 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. info for 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; an approval-needed ticket without a CLEAR ACTION + REASON gets bounced.
  • Permanent escape hatches. approval-needed must 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 validCategory function) 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 warning consumer (factory Actions budget tile).
  • warp#1715, warp#1655, warp#1668 — sister tickets that should be filed as warning / incident / approval-needed once Phase 1 lands.