Spec: Issues
- Status: Draft
- Last amended: 2026-06-05 (ADR-0034 — issue-bound todos, typed links, session binding)
- Constrained by: ADR-0005, ADR-0017, ADR-0018, ADR-0019, ADR-0020, ADR-0022, ADR-0034
- Implements:
packages/daemon/,packages/storage/(planned)
Purpose
The issues system provides a structured way for guests and operators to surface, track, and resolve "unknown" requests that do not fit pre-built workflows. It serves as an inbox for project-scoped work, allowing the operator to triage narrative requests into executable actions (sessions or workflows) while maintaining a transparent audit trail for the filer.
This document is normative for:
- The SQLite schema for issues and updates.
- The issue status lifecycle and state transitions.
- The semantics of issue assignment to execution targets.
- The operator rephrasing flow and body preservation.
- The visibility model for guest vs. internal updates.
- The API contract for filing, triaging, and updating issues.
Constraints (from ADRs)
| Constraint | Source |
|---|---|
| Default storage is SQLite; issues are stored in daemon-owned tables | ADR-0005 |
| Filers include guests and operators; guests use daemon-managed credentials | ADR-0017 |
| Filing and viewing are gated by project-level permission grants | ADR-0018 |
| Issues can be resolved by running workflows; attachments reuse workflow mechanism | ADR-0019 |
| Issues are project-scoped, sequentially numbered, and operator-triaged | ADR-0020 |
Schema
The issues system uses two primary tables in the daemon's SQLite database.
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);
Search (FTS5)
The body and title columns are indexed using SQLite FTS5 to support full-text search across the issue tracker. This is enabled by default in the daemon per ADR-0020.
Issue-bound todos (ADR-0034)
Per ADR-0034, the agent's working checklist is stored as issue-owned todos, mutated through the root-only kaged.todo tool. Todos come in two kinds — operator-owned criterion and agent-owned step.
CREATE TABLE issue_todos (
id TEXT PRIMARY KEY, -- ULID
issue_id TEXT NOT NULL,
content TEXT NOT NULL, -- task description, max 500 chars
status TEXT NOT NULL, -- "pending", "in_progress", "completed", "abandoned"
kind TEXT NOT NULL, -- "step" (agent working plan) or "criterion" (acceptance criteria)
origin_agent TEXT NOT NULL, -- caller tree-path: "primary", "primary.subagents.backoffice", ...
phase TEXT, -- optional work-stage grouping
position INTEGER NOT NULL, -- ordering within (issue, phase)
notes TEXT NOT NULL DEFAULT '[]', -- JSON array of append-only notes
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
completed_at INTEGER,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX idx_issue_todos_issue ON issue_todos(issue_id, position);
CREATE INDEX idx_issue_todos_issue_status ON issue_todos(issue_id, status);
Two kinds of todo:
step— the agent's working plan. The agent freelyset/add/start/done/drop/notes steps.criterion— acceptance criteria; the definition of done. Authored by the operator (UI) or proposed by the agent during discovery. A criterion can bedone-claimed by the agent but onlydrop-ed by the operator. A criterion'sdoneis a claim, not an autonomous close.
Single-in_progress invariant: At most one todo per issue is in_progress at a time. The handler enforces this server-side on every mutation, auto-demoting any existing in_progress todo to pending when a new one is started.
Markdown round-trip: The list serialises to a checklist the operator can read and hand-edit in the issue UI (- [ ] / - [x] / - [>] / - [-]), and parses back. Operator edits and agent ops mutate the same representation.
Volatile scratch vs durable audit: Per-task notes and routine status churn stay on the todo row and do not spam the issue_updates stream. Only significant lifecycle events emit an issue_updates row of kind=system_note: a criterion completed, a subagent sublist approved, the issue closed against its criteria.
Typed issue links (ADR-0034)
A small typed-link vocabulary for navigational relationships between issues:
CREATE TABLE issue_links (
id TEXT PRIMARY KEY, -- ULID
project_id TEXT NOT NULL,
from_issue TEXT NOT NULL,
to_issue TEXT NOT NULL,
kind TEXT NOT NULL, -- "child_of", "duplicate_of", "blocked_by", "relates_to"
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (from_issue) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (to_issue) REFERENCES issues(id) ON DELETE CASCADE
);
CREATE INDEX idx_issue_links_from ON issue_links(from_issue);
CREATE INDEX idx_issue_links_to ON issue_links(to_issue);
CREATE INDEX idx_issue_links_project ON issue_links(project_id);
Link kinds:
| Kind | Direction | Meaning |
|---|---|---|
child_of |
directional | from_issue is a decomposition of to_issue |
duplicate_of |
directional | from_issue is a duplicate of to_issue |
blocked_by |
directional | from_issue cannot proceed until to_issue resolves |
relates_to |
symmetric | Navigational association, no workflow semantics |
Inverse views (blocks, parent_of) are computed, not stored.
Links are navigational metadata, not a workflow engine. A blocked_by link does not gate transitions; a child_of link does not roll up status. Links inform the operator and power UI affordances (close-as-duplicate sets duplicate_of and transitions the source to rejected/resolved with a note).
Auto-parenting: An issue created within a session that has a bound issue — whether by the agent via kaged.issue create or by the operator from that session's surface — is automatically given a child_of link to the bound issue. This captures "while working #42 I found we also need #57" as provenance. Issues filed from the global issue list, outside a bound session, are not auto-parented.
Status Lifecycle
Status Enum
| Status | Set by | Meaning |
|---|---|---|
open |
filer (creation) | New, untriaged request. |
triaged |
operator | Acknowledged; pending decision on implementation. |
assigned |
operator | Bound to an execution target (workflow, agent, or session). |
in_progress |
system/operator | Work is currently executing in the assigned context. |
resolved |
operator/system | Request successfully addressed. |
rejected |
operator | Decision not to proceed; requires explanatory comment. |
reopened |
filer/operator | Returns a resolved issue to triaged for further work. |
State Transitions
open ──► triaged ──► assigned ──► in_progress ──► resolved
│ │ │ ▲
▼ ▼ │ │
rejected rejected │ (reopen)
│ │
└───┘
- Reopen Window: A guest filer may reopen an issue within 7 days of resolution. The operator may reopen an issue at any time. Reopened issues return to the
triagedstate. - Auto-transitions: An issue assigned to a workflow transitions to
in_progresswhen the run starts andresolvedwhen the run succeeds.
Assignment Semantics
The assignment field defines the intended execution context:
null: Unassigned. The default foropenissues."primary": Assigned to the project's primary agent. This typically involves the operator rephrasing the issue into a prompt."workflow:<name>": Assigned to a specific named workflow defined in the project DSL."session:<sid>": Pinned to an existing active session.
Assignment is an intent. The actual execution (spawning the session or running the workflow) is a discrete action initiated by the operator.
Operator Rephrasing
Operators are encouraged to rephrase guest requests into technical briefs.
- When editing the body, the previous content is moved to
original_body. - A
body_editupdate is recorded. - The edited
bodybecomes the authoritative version used for agent prompting and guest viewing. - UI displays an "Edited by operator" badge with access to the original text.
Visibility
visibility: "all": Visible to the operator and any guest with appropriate permissions (issues.view_ownorissues.view_all).visibility: "operator_only": Internal notes, triage observations, or system debugging info visible only to the operator.- Operators may toggle visibility of an existing update post-hoc.
User Experience
Filing (Guest/Operator)
- Per-project Numbering: Issues are assigned a sequential number (e.g.,
#42) unique within the project, reset per project. - Form: Minimal form with Title (max 200 chars) and Body (Markdown, max 16 KB).
- Attachments: Reuses the workflow upload mechanism (ADR-0019). Files are linked to the issue upon submission.
- Submit triggers
issue.filedaudit event.
Triage (Operator)
- List View:
/projects/:id/issues. Filtered by status (default: non-terminal) and creator. - Detail View:
/projects/:id/issues/:number. - Action Bar:
- Assign: Pick target.
- Run Workflow against this: Opens workflow picker and input form, auto-populated where possible.
- Send to agent: Creates a new session titled from the issue, binds it to that issue, and seeds the first message with the operator's prompt plus a hint to fetch issue
#N. The session starts inidlestate withbound_issueset. From there the operator steers — discovery (the agent gathers information and records it viakaged.issue comment) or implementation (the agent works a todo list) — by prompting. There is no discovery/implementation mode state; it is behavioural. - Reject with note: Closes issue with a required explanatory comment.
- Bind/unbind: The session sidebar exposes bind/unbind controls. Binding is a session-side pointer, not a state transition on the issue or the todos. Binding and unbinding from the UI/sidebar attaches or detaches the pointer; it does not mutate, clear, or complete stored todos. Unbinding simply puts the existing (possibly incomplete) todos out of reach of the current session. Re-binding the same issue rehydrates them.
- Promote to issue: A session that started without an issue and discovers mid-flight that it needs a durable plan can file an issue and bind the session to it in one step.
Closure flow (ADR-0034)
When the agent has marked the last open criterion done, it calls kaged.checkpoint with a reason like "acceptance criteria met, requesting sign-off." The session pauses. The operator reviews (and at sign-off implicitly ratifies the criteria set), then resumes; the resume path transitions the issue to resolved (the agent does this via kaged.issue transition, or the operator does it from the UI) and the session may conclude. kaged.ask / kaged.form carry any structured sign-off detail. No new closure primitive is introduced.
This resolves the standing open question in session-manager.md ("should manual session termination auto-resolve an assigned issue, or should the operator confirm?"): the operator confirms, at the closing checkpoint. Ending a session never silently resolves its issue.
Audit Events
| Event | Metadata |
|---|---|
issue.filed |
issue_id, project_id, creator_id, title |
issue.status_changed |
old_status, new_status |
issue.assigned |
target |
issue.body_edited |
Records that rephrasing occurred |
issue.commented |
update_id, kind: "comment" |
issue.attachment_uploaded |
file_id, filename, size |
issue.resolved |
resolved_by |
issue.rejected |
reason_summary (from comment) |
issue.reopened |
who |
API Summary
Operator Endpoints
GET /api/v1/projects/:id/issues— List all issues in project.POST /api/v1/projects/:id/issues— File a new issue.GET /api/v1/projects/:id/issues/:number— Get issue detail + update stream.PUT /api/v1/projects/:id/issues/:number— Update status, assignment, or rephrase body.POST /api/v1/projects/:id/issues/:number/updates— Add comment or system note.
Guest Endpoints
(Prefix: /api/v1/g/)
GET /projects/:id/issues— List issues (filtered byview_ownorview_allgrant).POST /projects/:id/issues— File new issue (iffilegranted).GET /projects/:id/issues/:number— View specific issue.POST /projects/:id/issues/:number/updates— Add comment to own issue (ifcomment_owngranted).
Author Identity
Every issue and issue-update response includes a resolved author identity alongside the raw createdBy/author userId string. This is the single source of truth for rendering attribution in the UI — no client-side lookups, no extra round-trips.
export type AuthorKind = "operator" | "guest" | "agent";
export interface AuthorIdentity {
kind: AuthorKind; // derived from userId prefix
user_id: string; // raw stored value
handle: string | null; // guests only; operators/agents → null
display_name: string | null;
}
Kind derivation is by user_id prefix:
"guest:…"→kind: "guest". Handle and display_name resolved from thegueststable; if the row no longer exists, both fields arenull(orphaned)."agent:…"→kind: "agent". Both fields arenull(no agent identity registry exists yet — userId itself is the label).- Anything else →
kind: "operator".display_nameresolves fromoperator_nameinlocal.toml;handleis alwaysnull(operators don't have handles).
Wire shape is consistent across operator and guest endpoints, even though the operator-side response keys use camelCase and the guest-side uses snake_case:
- Issue summary/detail responses gain
author: AuthorIdentity. - Issue update responses gain
author_identity(snake_case, guest side) /authorIdentity(camelCase, operator side). The originalauthor: stringuserId field is preserved on updates for permission checks ("is this my comment?") and audit-log diffing.
Display rendering in the UI (AuthorBadge primitive):
- Operator →
display_nameif set, else literal"Operator". Icon:User(lucide). - Guest →
"{display_name} ({handle})"if both, elsehandle, elsedisplay_name, elseuser_id. Icon:UserCircle. - Agent →
user_idverbatim. Icon:Bot.
The resolver memoizes per-request and batches lookups for issue/update lists.
Types
import { z } from "zod";
export const IssueStatusSchema = z.enum([
"open",
"triaged",
"assigned",
"in_progress",
"resolved",
"rejected",
"reopened",
]);
export type IssueStatus = z.infer<typeof IssueStatusSchema>;
export const AssignmentTargetSchema = z.union([
z.null(),
z.literal("primary"),
z.string().regex(/^workflow:.+$/),
z.string().regex(/^session:.+$/),
]);
export type AssignmentTarget = z.infer<typeof AssignmentTargetSchema>;
export const IssueUpdateKindSchema = z.enum([
"comment",
"status_change",
"assignment_change",
"title_edit",
"body_edit",
"system_note",
]);
export const IssueSchema = z.object({
id: z.string().regex(/^01[0-9A-HJKMNP-TV-Z]{24}$/), // ULID
project_id: z.string(),
number: z.number().int().positive(),
created_by: z.string(),
title: z.string().max(200),
body: z.string().max(16384),
original_body: z.string().nullable(),
status: IssueStatusSchema,
assignment: AssignmentTargetSchema,
created_at: z.number().int(),
updated_at: z.number().int(),
resolved_at: z.number().int().nullable(),
resolved_by: z.string().nullable(),
});
export const IssueUpdateSchema = z.object({
id: z.string().regex(/^01[0-9A-HJKMNP-TV-Z]{24}$/), // ULID
issue_id: z.string().regex(/^01[0-9A-HJKMNP-TV-Z]{24}$/),
author: z.string(),
kind: IssueUpdateKindSchema,
body: z.string().nullable(),
metadata: z.record(z.any()).nullable(),
visibility: z.enum(["all", "operator_only"]),
created_at: z.number().int(),
});
Issue bubble-up
Per ADR-0022 rule 10, subagents do not have kaged.issue.* tool access. The kaged.issue.* tools carry principal_scope: "root-only" and the schema rejects them on any non-root agent. Issue management is single-rooted at the primary agent.
Pattern
The bubble-up pattern keeps subagents domain-focused and the audit trail single-rooted:
Delegation framing. When the root agent delegates work related to an issue, it includes the relevant issue context (title, body, constraints) in the delegation message to the subagent. The subagent does not know it is "working on issue #42" — it receives a task description that happens to originate from an issue.
Subagent execution. The subagent performs its work using its own tool surface (file operations, search, etc.). It has no ability to create, update, comment on, or transition issues.
Return and interpretation. The subagent's return value bubbles back to the root agent. The root agent decides whether to update the issue based on the subagent's results — adding a comment, transitioning status, or requesting further work from another subagent.
Audit trail. All issue mutations are attributed to the root agent's session. The audit log records the subagent invocation that produced the result, but the issue state change itself is always performed by the root agent via
kaged.issue.*tools. This keeps the audit trail linear and human-reviewable.
Why not direct subagent access?
- Single audit root. If subagents could file or transition issues, the audit trail would fork across agents with different cage policies and tool surfaces. Debugging "who changed issue #42 and why" becomes a cross-agent trace instead of a single-agent log.
- Domain isolation. Subagents are specialists (scraper, deployer, builder). Giving them issue tools would couple domain work to project-management concerns. The root agent mediates.
- Cage implications. Subagents may run in restricted cages. Issue operations require daemon API access; granting that to caged subagents would punch through the cage boundary for a non-domain concern.
Worked example
Root agent receives: "Scrape new releases and file issues for each."
1. Root calls subagent "scraper" with:
"Find all new releases published in the last 24 hours.
Return a list of {name, version, url} for each."
2. Scraper returns:
[{name: "libfoo", version: "2.1.0", url: "..."}, ...]
3. Root agent iterates over results and calls kaged.issue.create for each:
kaged.issue.create({ title: "New release: libfoo 2.1.0", body: "..." })
4. Audit log shows: root agent created issues #43, #44, #45.
Each audit entry links to the scraper invocation that produced the data.
Failure Modes
- Sequential Race: Multiple simultaneous filings in one project. Handled via SQLite transaction with
IMMEDIATElock andUNIQUEindex on(project_id, number). - Orphaned Attachments: Files uploaded but issue never submitted. Handled by TTL cleanup (e.g., 24h) in the attachment store.
- Assignment Drift: Workflow deleted while an issue is still assigned to it. UI must handle missing targets gracefully (fallback to unassigned view).
Testing Notes
- State Machine: Unit tests for all valid/invalid transitions.
- Permissions: Verify guests cannot see
operator_onlyupdates or issues they don't own (unlessview_all). - Persistence: Ensure
original_bodyis preserved on first edit and never overwritten by subsequent edits. - Search: Verify FTS5 query results match content in both title and body.
Open Questions
- Reopen Window: Should the 7-day window be configurable per-project in the local configuration?
- Auto-resolution:
Should manual session termination automatically resolve an assigned issue, or should the operator confirm?Resolved (ADR-0034): The operator confirms, at the closing checkpoint. Ending a session never silently resolves its issue. - Phases in v1, or defer?
phaseis lifted from oh-my-pi and is cheap (a nullable string), but it is a third grouping axis alongsidekindandorigin_agent. Lean ship it as optional — a flat list works if unused — but it could be deferred to keep the first cut minimal. - Agent-proposed criteria. Should the agent be able to create
criteriontodos directly during discovery, or onlysteps that the operator promotes to criteria? Current lean: the agent may propose criteria, the operator ratifies the set at the closing checkpoint, and only the operator candropone. A lighter-weight per-criterion approval might be warranted; revisit if sign-off feels too coarse. - Closing a parent with open children. Does resolving a parent prompt on, block on, or ignore open
child_ofissues? Lean prompt, don't block — links are navigational, not gating — but the UI should surface the open children at closure time.
References
- ADR-0020: Issues
- ADR-0022: Recursive agents; per-agent config — issue bubble-up pattern;
kaged.issue.*root-only - Spec: Daemon
- Spec: Agent Tooling —
kaged.issue.*tool definitions andprincipal_scope - Spec: Session Manager
- Spec: Workflows
Amendments
2026-05-26 — ADR-0022: issue bubble-up pattern; kaged.issue.* tools root-only
ADR-0022 introduces per-agent tool surfaces and restricts kaged.issue.* tools to the root agent via principal_scope: "root-only". This amendment adds:
- Issue bubble-up section. Documents the pattern by which subagents contribute to issue resolution without direct issue access: the root agent frames delegation with issue context, subagents return results, and the root agent decides how to update the issue. Includes a worked example and rationale.
- Constraint added. ADR-0022 added to the constrained-by list — the bubble-up pattern and root-only restriction are ADR-level commitments.
- Reference added. Cross-references to
agent-tooling.mdfor thekaged.issue.*tool definitions andprincipal_scopeenforcement.
2026-06-05 — ADR-0034: issue-bound todos, typed links, session binding
ADR-0034 introduces issue-owned todos, typed navigational links, and session-to-issue binding. This amendment adds:
- Issue-bound todos section. New
issue_todostable schema with two kinds (step/criterion), single-in_progressinvariant, markdown round-trip, volatile-scratch-vs-durable-audit distinction. Todos are mutated through the root-onlykaged.todotool (defined inagent-tooling.md). - Typed issue links section. New
issue_linkstable schema with four link kinds (child_of,duplicate_of,blocked_by,relates_to). Links are navigational metadata, not a workflow engine — no automatic blocking, no status roll-up. - Auto-parenting. Issues created within a bound session automatically receive a
child_oflink to the bound issue. - Session binding. Sessions carry a
bound_issuepointer (nullable). Binding/unbinding is a session-side operation that does not mutate todos. Thekaged.todotool requires a bound issue; without one it errors with guidance to bind. - "Send to agent" action. Replaces "Open in new session" in the triage action bar. Creates a session, binds it to the issue, seeds the first message.
- Closure flow. Documents the operator-confirmed closure path: agent claims criteria done →
kaged.checkpoint→ operator reviews → resume transitions issue toresolved. Resolves the auto-resolution open question. - Constraint added. ADR-0034 added to the constrained-by list.
- Open questions updated. Auto-resolution resolved; three new open questions added (phases, agent-proposed criteria, closing parent with open children).