Spec: Issues

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 freely set/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 be done-claimed by the agent but only drop-ed by the operator. A criterion's done is 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 triaged state.
  • Auto-transitions: An issue assigned to a workflow transitions to in_progress when the run starts and resolved when the run succeeds.

Assignment Semantics

The assignment field defines the intended execution context:

  • null: Unassigned. The default for open issues.
  • "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.

  1. When editing the body, the previous content is moved to original_body.
  2. A body_edit update is recorded.
  3. The edited body becomes the authoritative version used for agent prompting and guest viewing.
  4. 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_own or issues.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.filed audit 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 in idle state with bound_issue set. From there the operator steers — discovery (the agent gathers information and records it via kaged.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 by view_own or view_all grant).
  • POST /projects/:id/issues — File new issue (if file granted).
  • GET /projects/:id/issues/:number — View specific issue.
  • POST /projects/:id/issues/:number/updates — Add comment to own issue (if comment_own granted).

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 the guests table; if the row no longer exists, both fields are null (orphaned).
  • "agent:…"kind: "agent". Both fields are null (no agent identity registry exists yet — userId itself is the label).
  • Anything else → kind: "operator". display_name resolves from operator_name in local.toml; handle is always null (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 original author: string userId 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_name if set, else literal "Operator". Icon: User (lucide).
  • Guest → "{display_name} ({handle})" if both, else handle, else display_name, else user_id. Icon: UserCircle.
  • Agent → user_id verbatim. 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:

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

  2. 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.

  3. 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.

  4. 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 IMMEDIATE lock and UNIQUE index 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_only updates or issues they don't own (unless view_all).
  • Persistence: Ensure original_body is preserved on first edit and never overwritten by subsequent edits.
  • Search: Verify FTS5 query results match content in both title and body.

Open Questions

  1. Reopen Window: Should the 7-day window be configurable per-project in the local configuration?
  2. 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.
  3. Phases in v1, or defer? phase is lifted from oh-my-pi and is cheap (a nullable string), but it is a third grouping axis alongside kind and origin_agent. Lean ship it as optional — a flat list works if unused — but it could be deferred to keep the first cut minimal.
  4. Agent-proposed criteria. Should the agent be able to create criterion todos directly during discovery, or only steps 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 can drop one. A lighter-weight per-criterion approval might be warranted; revisit if sign-off feels too coarse.
  5. Closing a parent with open children. Does resolving a parent prompt on, block on, or ignore open child_of issues? Lean prompt, don't block — links are navigational, not gating — but the UI should surface the open children at closure time.

References


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:

  1. 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.
  2. Constraint added. ADR-0022 added to the constrained-by list — the bubble-up pattern and root-only restriction are ADR-level commitments.
  3. Reference added. Cross-references to agent-tooling.md for the kaged.issue.* tool definitions and principal_scope enforcement.

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:

  1. Issue-bound todos section. New issue_todos table schema with two kinds (step / criterion), single-in_progress invariant, markdown round-trip, volatile-scratch-vs-durable-audit distinction. Todos are mutated through the root-only kaged.todo tool (defined in agent-tooling.md).
  2. Typed issue links section. New issue_links table 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.
  3. Auto-parenting. Issues created within a bound session automatically receive a child_of link to the bound issue.
  4. Session binding. Sessions carry a bound_issue pointer (nullable). Binding/unbinding is a session-side operation that does not mutate todos. The kaged.todo tool requires a bound issue; without one it errors with guidance to bind.
  5. "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.
  6. Closure flow. Documents the operator-confirmed closure path: agent claims criteria done → kaged.checkpoint → operator reviews → resume transitions issue to resolved. Resolves the auto-resolution open question.
  7. Constraint added. ADR-0034 added to the constrained-by list.
  8. Open questions updated. Auto-resolution resolved; three new open questions added (phases, agent-proposed criteria, closing parent with open children).