ADR-0020: Issues are daemon-stored work items routed through operator triage
- Status: Accepted
- Date: 2026-05-25
- Deciders: @karasu
- Supersedes: —
- Superseded by: —
Context
Workflows (ADR-0019) cover the known asks — recurring jobs the operator has authored a recipe for. They do not cover the unknown asks: a client asking "can you also add the year to each photo caption?" or "the contact form is broken, can you check?" These don't fit a pre-built workflow. They need a way for a guest to surface a request, for the operator to triage it, and for the guest to see what happened.
The temptation is to lean on chat: the guest messages the agent, the operator catches up later. This fails for the same reason workflows beat free-form chat — without structure, requests get lost. A chat thread is a transcript, not an inbox.
The structure we need is the same one every collaborative tool eventually invents: an issue tracker. A small one. Operator-triaged. Project-scoped.
The relevant constraints:
- Daemon-stored, not external. Bringing in GitHub Issues / Linear / Jira via a plugin is plausible later, but not in v1. The base case is internal to kaged.
- Project-scoped. An issue belongs to exactly one project. No cross-project meta-issues.
- Both principals file. Operators file issues too (for their own future selves; "remember to check the deploy script"). Guests file gated by their grant's
issues.filepermission (ADR-0018). - Operator triages. Guests never assign, never close someone else's issue, never see the operator's triage notes. Operator owns state transitions.
- Status is small. No story points, no epics, no labels-with-colors. A handful of states. If we find ourselves wanting more, we revisit.
- Resolution is via the same tools the operator already has. Run a workflow against the issue. Open a session and work it manually. Rephrase and assign to the primary agent. Reject with a note. Resolution is what the operator does; the issue tracks that they did it.
- The guest sees real updates. Status changes, operator comments (when shared), final result. Not a black box.
Naming: issue. Not "task" (taken by ADR-0019... wait, actually tasks: is shell commands per task-runner.md, which is what we said earlier — still taken). Not "request" (vague). Not "ticket" (too IT-helpdesk). "Issue" is universally understood from GitHub/Jira/Linear, and it's accurately neutral about what state the work is in.
Decision
kaged adds a daemon-owned
issuestable per project, with a small status enum, a separateissue_updateschild table for the comment/status-change stream, and triage flow that the operator drives from the project UI. Guests can file issues (gated by ADR-0018issues.file), see their own (gated byissues.view_own), and add updates to their own (gated byissues.comment_own). All state transitions are operator-only.
Schema
CREATE TABLE issues (
id TEXT PRIMARY KEY, -- ULID
project_id TEXT NOT NULL,
number INTEGER NOT NULL, -- per-project sequential, for human reference (#42)
created_by TEXT NOT NULL, -- user_id (operator or guest:<ulid>)
title TEXT NOT NULL, -- max 200 chars
body TEXT NOT NULL, -- markdown; max 16 KB
original_body TEXT, -- preserved on operator edit; null if never edited
status TEXT NOT NULL, -- enum below
assignment TEXT, -- null, "primary", "workflow:<name>", "session:<sid>"
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
resolved_at INTEGER,
resolved_by TEXT, -- user_id
FOREIGN KEY (project_id) REFERENCES projects(id)
);
CREATE TABLE issue_updates (
id TEXT PRIMARY KEY, -- ULID
issue_id TEXT NOT NULL,
author TEXT NOT NULL, -- user_id; "system" for daemon-generated entries
kind TEXT NOT NULL, -- "comment", "status_change", "assignment_change", "title_edit", "body_edit", "system_note"
body TEXT, -- comment text; or null for pure status/assignment entries
metadata TEXT, -- JSON blob; structure varies by kind
visibility TEXT NOT NULL, -- "all", "operator_only"
created_at INTEGER NOT NULL,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX idx_issues_project_status ON issues(project_id, status, updated_at DESC);
CREATE INDEX idx_issues_creator ON issues(created_by, created_at DESC);
CREATE INDEX idx_issue_updates_issue ON issue_updates(issue_id, created_at);
CREATE UNIQUE INDEX idx_issues_project_number ON issues(project_id, number);
Status enum
| Status | Set by | Meaning |
|---|---|---|
open |
filer (creation) | New, untriaged. |
triaged |
operator | Operator has acknowledged and not yet decided how to handle. Edits to title/body may have happened. |
assigned |
operator | Operator has assigned to a workflow, the primary agent, or a session, but work hasn't started. |
in_progress |
system (or operator) | The assigned workflow/session is running. Auto-transitioned when work starts; can be manually set. |
resolved |
operator (or auto) | Work completed successfully. Auto when an assigned workflow run succeeds; manual for sessions. |
rejected |
operator | Operator decided not to do this. Comment required explaining why. Visible to filer. |
reopened |
filer (within window) or operator | A resolved issue can be reopened by its filer within 7 days (configurable); operator can reopen anytime. Returns to triaged. |
State transitions:
open ──► triaged ──► assigned ──► in_progress ──► resolved
│ │ │ ▲
▼ ▼ │ │
rejected rejected │ (reopen)
│ │
└───┘
A rejected issue does not auto-reopen. The filer files a new issue if they disagree.
Assignment semantics
The assignment column is a string with a small grammar:
null— unassigned. Default foropenissues."primary"— operator wants the project's primary agent to handle it in a new session. Operator typically rephrases the body first (see below)."workflow:<name>"— operator wants to run a specific workflow. Inputs must be supplied by the operator (the issue body is a brief, not validated inputs)."session:<sid>"— operator pinned the issue to a specific session they're working in. Updates to the session are reflected assystem_noteupdates on the issue (e.g. "session reached resolution: deployed staging").
The assignment column is informational; it does not auto-spawn anything. Spawning is an explicit operator action via the issue UI ("Assign and run").
Operator rephrasing
When an operator assigns an issue to primary or workflow:<name>, the UI offers an edit body step. The original guest-supplied body is preserved in original_body; the edited body becomes body. An issue_updates row of kind body_edit records the change. The filer sees the edited body going forward (with a "edited by operator" badge) — the principle being that the operator's framing is the authoritative version of the request, but the original isn't lost.
This is the explicit "operator-rephrases-against-project-knowledge" step from the design conversation. It's encouraged but not required; an operator can assign with the original body intact.
Visibility
Each issue_update has a visibility field:
all— visible to the filer (and any other guest withissues.view_all) and the operator.operator_only— internal notes; only the operator sees them.
Operators can flip an update's visibility post-hoc (e.g., realising a triage note can safely be shared). Filers cannot create operator_only updates.
Filing UX
A guest with issues.file in their grant sees a "File an issue" button on the project view. The form is minimal: title, body (markdown), optional file attachments (same upload mechanism as workflow inputs per ADR-0019, reused for issue attachments). On submit, the issue lands in open status, the operator sees a notification badge on the project, and the audit log records issue.filed.
Operators can file issues directly from the operator project view via the same UI.
Operator triage UX
/projects/:id/issues shows the project's issue list with filters by status and creator. Default filter is status != resolved AND status != rejected. Clicking an issue opens /projects/:id/issues/:number which shows:
- Title, body (editable), original body if edited.
- Status (changeable via dropdown).
- Assignment (changeable via dropdown).
- Update stream (chronological).
- Action bar: "Assign", "Run workflow against this", "Open in new session", "Reject with note".
The action bar's primary actions wrap the underlying mechanism. "Run workflow against this" pops a workflow picker, then a workflow input form (per ADR-0019) — the operator fills in the formal inputs derived from the issue's narrative.
Audit semantics
New audit events on top of ADR-0017 and ADR-0018:
issue.filed— title, creator, project.issue.status_changed— old + new status, who.issue.assigned— assignment target, who.issue.body_edited— diff omitted from audit (lives inoriginal_bodycolumn); records that an edit happened.issue.commented— adds an update ofkind=comment.issue.attachment_uploaded— file metadata, not content.issue.resolved,issue.rejected,issue.reopened— terminal/reopen transitions.
All carry user_id and issue_id.
Consequences
What this commits us to
- An
issuestable and anissue_updatestable, with the schemas above, in SQLite per ADR-0005. - Per-project sequential numbering (
#42-style), reset per project. Useful for human reference. - API endpoints under
/api/v1/projects/:id/issues/*(mixed: operator-only for state transitions, guest-permitted for filing and own-view). - UI: operator project view gets
/projects/:id/issues(list) and/projects/:id/issues/:number(detail). Guest project view gets/g/:project_id/issuesand/g/:project_id/issues/:number(ADR-0021). - A modest amount of issue-related UX: filters, sort, basic search by title. No labels, no milestones, no roadmaps.
- An
original_bodycolumn that survives edits and is shown alongside the edited version for transparency. - Audit log events at every transition.
- Reuse of the workflow file-upload protocol for issue attachments.
- An action of "assign-and-run" that bridges issues to workflows: the operator picks a workflow, fills its inputs (referencing the issue body as context), and the resulting run is linked back to the issue.
What this forecloses
- No external issue tracker integration in v1. GitHub/Linear/Jira are plausible v1.x via a plugin (ADR-0008) but not core.
- No labels, milestones, or epics. Status + assignment + comment stream is the entire vocabulary. If operators want more, they're using kaged as a project management tool, which it isn't.
- No issue dependencies. A workflow may block on an issue informally (operator decides), but the daemon doesn't track "issue A blocks issue B."
- No assignee separate from the operator. Operators are the only ones who can be assigned to triage. There is no "assign to another operator" in v1; there is one operator.
- No automatic guest notifications. No SMTP/SMS per ADR-0017. Guests see status changes when they next open
/g/:project_id/issues/:number. A v1.x in-app indicator (next to the project tile) is plausible.
What becomes easier
- The "I'd like X done that doesn't fit an existing workflow" use case has a real surface.
- Operator inbox: every project's outstanding issues are one click away.
- Audit trail of every decision: what was filed, what was decided, what was done.
- Bridging unstructured requests to structured workflows: "this issue was resolved by running workflow Y with inputs Z" is a first-class link.
- Operator's institutional memory of the project: every past issue is a searchable record.
What becomes harder
- The UI has a real issue-list and issue-detail surface to build. Not enormous, but non-trivial.
- File attachments on issues need lifecycle management (TTL on unattached uploads, cleanup on issue deletion).
- Status enum is finite by design; operators who want a state we didn't pre-pick will be frustrated. Documented tradeoff.
- Operator carries the entire triage burden. Documented; not a goal to scale beyond one operator (the manifesto already says so).
- Per-project
numbersequence requires a per-project counter; not hard (a SELECT MAX + 1 in a transaction), but a small bit of plumbing in the issue-create path.
Alternatives considered
Alternative A — No issues; use chat sessions
Why tempting: Sessions already exist. A guest's "can you also add X" is just a chat message.
Why rejected: Sessions are conversations. They have no status, no triage state, no inbox. An operator with five active projects has five conversations to monitor; an inbox of issues across projects is a saner ops surface. Issues are explicitly structured for the "I want to track this until it's done" workflow.
Alternative B — Plug in an external tracker (GitHub Issues via a plugin)
Why tempting: Operators already use one. Don't reinvent.
Why rejected: Two surfaces to manage, two ACL models, two audit trails. The base case (guest files, operator triages) is daemon-internal — the guest has no GitHub account in the operator's repo. An external tracker is plausible as a v1.x additional destination via a plugin; the core flow stays internal.
Alternative C — Full project management: labels, milestones, sprint planning
Why tempting: It's the standard issue-tracker shape. Operators understand it.
Why rejected: kaged is not a PM tool. The manifesto is explicit. Issues are the minimum useful artifact for "guest wants a thing, operator decides what to do about it." Beyond that, the operator uses their own PM tool.
Alternative D — File issues from inside agent sessions (the agent files an issue when it gets stuck)
Why tempting: The agent could surface "I can't finish this; filing an issue for the operator" automatically.
Why rejected: Maybe useful, deferred. The current checkpoint protocol (session-manager.md) already covers "agent requests operator attention." Filing an issue from a checkpoint is a UI shortcut on top of existing primitives, not a separate decision. Add when the use case demands.
Alternative E — Threaded comments (replies to specific updates)
Why tempting: Familiar from GitHub.
Why rejected: Flat comment stream is sufficient for the volume of conversation issues will see in this use case. Threading is complexity for low return. Operators who want to reply to a specific update can quote it inline.
Open questions
- Reopen window default. 7 days is a guess. May want 30 for slower-paced projects, 24 hours for fast deploys. Per-project config plausibly; default 7 days.
- Issue templates. Should projects be able to define issue templates in the DSL (title prefix, body skeleton)? Useful for guiding guests to give the right info upfront. Probably yes for v1.x; not v1.
- Search. Title search only in v1, or full-text body search? FTS5 is built into SQLite; the cost is small. Lean yes, FTS5 enabled on body. Document in spec.
- Closing an issue without resolution. Sometimes the issue becomes moot (guest decides they don't want it). Currently expressed as
rejectedwith a note like "filer withdrew." A separatecancelledstatus might be cleaner; lean no —rejectedwith appropriate note is fine and keeps the enum small. - Operator's own-filed issues visibility. An operator filing an issue on a project — is that visible to guests with
issues.view_all? Lean yes; the operator can mark the issue update visibilityoperator_onlyfor the body if needed. Consistent treatment. - Per-issue notification preferences. "Notify me when this changes" subscriptions. v1.x; not v1.
- Linking workflow runs back to issues. Each workflow run that resolves an issue should be reachable from the issue (e.g., "resolved by workflow run 01HXAB..."). Solved at the schema level by
assignment+ anissue_updatesrow ofkind=system_noterecording the run id; UI surfaces a link. Documented as the expected pattern.
References
- ADR-0005 — storage for issues + updates tables
- ADR-0008 — route for any future external tracker bridge
- ADR-0011 — issues are operator-state, not portable
- ADR-0017 — guests who file issues
- ADR-0018 —
issues.*permission_set fields that gate filing/viewing/commenting - ADR-0019 — workflows that operators run against issues
- ADR-0021 — guest UI where issues are filed and viewed
docs/specs/issues.md— implementation spec (to be written)docs/specs/session-manager.md— sessions that may be linked to issues- SQLite FTS5: https://www.sqlite.org/fts5.html
- Original discussion: design conversation with colleagues, 2026-05-25