# Overdeck Incidents page — Kanboard store and agent dispatch

audience: AI coding agents first.

Slug: `incidents-page`. Date: 2026-08-08.

## 1. Decision and scope

Build `/incidents` as an observation-and-filing surface over a headless Kanboard
deployment. Kanboard is the authoritative incident store. Overdeck reads and
writes it only through Kanboard's JSON-RPC 2.0 API. Do not build, persist, or
embed another kanban board.

An operator can:

- see active and resolved incidents, their priority, handling state, age, and
  recorded agent activity;
- open one incident for full detail;
- file an incident with title, description, CLI, model, reasoning effort,
  account, an explicit `Unsafe` checkbox, and `Priority`;
- observe the one agent attempt launched for that filing and its terminal
  disposition.

This design does not add general task management, task drag-and-drop, arbitrary
Kanboard project access, manual agent retry, incident editing, assignment,
attachments, or a Kanboard UI iframe. It does not replace existing
source-specific alerts or normalized collector `Item`s. Importing or
deduplicating those alerts into Kanboard is a separate design.

Backend choice is closed: [Kanboard](https://github.com/kanboard/kanboard) is
MIT-licensed and is not open for re-evaluation in implementation.

## 2. Authority and topology

The authority chain is:

```text
browser /incidents
  -> same-origin, exact-path Overdeck web proxy
  -> collector IncidentService
  -> Kanboard JSON-RPC 2.0 /jsonrpc.php
  -> PostgreSQL

collector IncidentService
  -> IncidentDispatchLauncher
  -> one transient, non-restarting foreground incident-agent process
  -> existing registered engine wrapper
  -> status writes back through Kanboard JSON-RPC
```

The browser never talks directly to Kanboard and never receives its address,
Basic-auth value, application token, database credential, wrapper path, account
credential, or workspace path. “The Overdeck UI talks to Kanboard over JSON-RPC”
means the typed server-side IncidentService is the UI's Kanboard adapter; it
does not permit browser-side JSON-RPC or direct database access.

Kanboard task state plus `overdeck.*` task metadata is durable incident truth.
The collector's polling snapshot, SSE projection, React state, systemd unit
state, process identity, and logs are projections or execution evidence only.
None may create, relaunch, or resolve an incident merely because a poll, page,
process, or timer is alive.

## 3. Kanboard deployment shape

### 3.1 Services

Deploy one rootless, local-only container pair, owned by user systemd:

- **Kanboard:** `ghcr.io/kanboard/kanboard:v1.2.52`; publish only
  `127.0.0.1:31339:80`; persist `/var/www/app/data` on a dedicated local volume.
- **Database:** `docker.io/library/postgres:17.9-alpine`; join only the private
  container network and publish no host port; persist `/var/lib/postgresql/data`
  on a dedicated local volume.

Release packaging must resolve and record each image digest before deployment;
production must run the recorded digest, not `latest`. Version upgrades are
explicit changes that read the Kanboard changelog, back up PostgreSQL, start the
candidate against a restored copy, run the JSON-RPC contract suite, and only
then replace the live digest.

The user-level service is `overdeck-kanboard.service`. It starts PostgreSQL
before Kanboard, uses `Restart=on-failure` for the store services, and is
included in the normal Overdeck install and health smoke. This is store
availability, not agent supervision. It must never launch or relaunch an
incident agent.

Use PostgreSQL rather than SQLite. Kanboard recommends PostgreSQL and warns
against SQLite with Docker. Keep both volumes on a local filesystem; NFS is
forbidden. Back up with `pg_dump`; copying a live database volume is not a
backup contract.

### 3.2 Configuration and credentials

Kanboard configuration is server-owned:

- `DATABASE_URL` points at the private PostgreSQL service.
- `PLUGIN_INSTALLER=false`; install no Kanboard plugins for this feature.
- `API_AUTHENTICATION_TOKEN` is a generated high-entropy value stored outside
  the repository in a mode-`0600` Overdeck environment file.
- JSON-RPC uses HTTP Basic auth with username `jsonrpc` and that application
  token. Application API credentials can call every procedure and bypass project
  permissions, so they are never exposed to the browser or written to logs.
- Public project access is disabled. Kanboard remains loopback-only and
  headless: Overdeck neither links to nor embeds Kanboard's web board.
- Health uses `GET http://127.0.0.1:31339/healthcheck.php`; readiness
  additionally makes a bounded authenticated `getProjectByIdentifier` JSON-RPC
  call. HTTP 200 from the database-only healthcheck is not enough to claim the
  incident contract is ready.

The JSON-RPC client has a three-second connect timeout and a ten-second response
timeout. It sends `Content-Type: application/json`, JSON-RPC version `2.0`, and
a unique opaque request id. It accepts neither redirects nor non-loopback
endpoints by default. Transport failure, non-2xx HTTP, malformed JSON,
response-id mismatch, a JSON-RPC `error`, or a method-specific `false`/`null`
failure result is an error; none becomes an empty healthy incident list.

### 3.3 Bootstrap-owned project

Provision exactly one private project:

```text
name        Overdeck Incidents
identifier  OVERDECKINCIDENTS
priority    start 0, end 3, default 2
```

Its columns, in this exact order, are:

1. `Filed`
2. `Dispatching`
3. `Running`
4. `Needs attention`
5. `Resolved`

Its active incident swimlanes, in this exact order, are:

1. `P0 · Critical`
2. `P1 · High`
3. `P2 · Normal`
4. `P3 · Low`

The default swimlane may remain present because Kanboard owns it, but no
Overdeck incident may be created in it. Bootstrap is idempotent: look up by
identifier, create when absent, rename/reorder known bootstrap-owned columns and
swimlanes when necessary, and add missing ones. Never delete an unknown project,
column, swimlane, task, or comment. Unknown structural additions make readiness
`degraded` and name the drift; they are not silently removed.

Provision one local attribution user named `overdeck-agent`. Its user id is used
only as `createComment.user_id`; JSON-RPC still authenticates with the
application credential. Do not use a human operator's user id or invent browser
identity.

## 4. JSON-RPC seam

### 4.1 Client boundary

Only a closed procedure union is callable:

```ts
type KanboardProcedure =
  | "getProjectByIdentifier"
  | "createProject"
  | "updateProject"
  | "getColumns"
  | "updateColumn"
  | "addColumn"
  | "changeColumnPosition"
  | "getAllSwimlanes"
  | "getActiveSwimlanes"
  | "getSwimlaneByName"
  | "addSwimlane"
  | "updateSwimlane"
  | "enableSwimlane"
  | "changeSwimlanePosition"
  | "getBoard"
  | "getAllTasks"
  | "getTask"
  | "getTaskByReference"
  | "createTask"
  | "updateTask"
  | "moveTaskPosition"
  | "closeTask"
  | "openTask"
  | "getTaskMetadata"
  | "saveTaskMetadata"
  | "getAllComments"
  | "createComment"
  | "getUserByName"
  | "createUser";

interface KanboardRpcClient {
  call<M extends KanboardProcedure>(
    method: M,
    params: KanboardParams[M],
  ): Promise<KanboardResult[M]>;
  batch<C extends readonly KanboardCall[]>(
    calls: C,
  ): Promise<KanboardBatchResult<C>>;
}
```

No generic procedure name from an HTTP request crosses this boundary. Numeric
ids and Unix-second fields returned as strings are normalized only after runtime
schema validation. Unknown response fields may be ignored; missing required
fields, duplicate batch ids, or a partial batch response fail the whole
projection.

### 4.2 Procedures and ownership

- **Bootstrap project:** `getProjectByIdentifier`, `createProject`, and
  `updateProject`. The identifier is the idempotency key; never select by
  display name alone.
- **Bootstrap columns:** `getColumns`, `updateColumn`, `addColumn`, and
  `changeColumnPosition`. Only the five named columns are Overdeck-owned.
- **Bootstrap swimlanes:** `getAllSwimlanes`, `getActiveSwimlanes`,
  `getSwimlaneByName`, `addSwimlane`, `updateSwimlane`, `enableSwimlane`, and
  `changeSwimlanePosition`. Preserve Kanboard ids; names are bootstrap keys.
- **List:** `getBoard`, `getAllTasks` for both status ids, and batched
  `getTaskMetadata`. The two task calls make resolved history explicit.
- **Detail:** `getTask`, `getTaskMetadata`, and `getAllComments`, batched. A
  missing task is 404, never an empty detail.
- **Idempotent filing lookup:** `getTaskByReference`, then a bounded scan of
  `getAllTasks`. The immutable description marker closes the pre-reference crash
  window.
- **File:** `createTask`, `updateTask`, and `saveTaskMetadata`. The create call
  includes Filed column, priority swimlane, native priority, description, and
  immutable marker before dispatch.
- **Agent status:** `saveTaskMetadata`, `moveTaskPosition`, and `createComment`.
  Metadata is truth; column and comment are projections.
- **Resolve:** `moveTaskPosition`, then `closeTask`. Closed tasks remain
  queryable; never remove them.

Do not use Kanboard automatic actions, plugins, webhooks, direct SQL,
`removeTask`, `removeProject`, `removeColumn`, or `removeSwimlane`. Runtime
status changes come only from the incident runner's explicit JSON-RPC writes.

### 4.3 Overdeck HTTP boundary

The browser receives a typed resource API, not raw JSON-RPC:

```ts
GET  /incidents/options
GET  /incidents?scope=active|resolved|all&query=<text>&priority=P0|P1|P2|P3
GET  /incidents/:incidentId
POST /incidents

type FileIncidentRequest = {
  requestId: string
  title: string
  description: string
  cli: string
  model: string
  reasoningEffort: string
  account: string
  unsafe: boolean
  priority: "P0" | "P1" | "P2" | "P3"
}

type FileIncidentResponse = {
  incident: Incident
  launch: { state: "starting" | "running"; dispatchId: string }
}
```

`requestId` is a browser-generated UUID and the filing idempotency key. The same
request with the same normalized body returns the same incident and never starts
a second agent. Reusing it with a different body returns
`409 idempotency-conflict`.

The collector serves these routes under its bearer-authenticated API. The Astro
proxy admits only the exact read paths and a dedicated `POST /incidents`
handler, applies its existing same-origin mutation check and 64 KiB body limit,
and forwards no arbitrary Kanboard method or path. Browser and collector schemas
reject unknown fields. Title is 1–160 trimmed Unicode characters; description is
1–20,000 characters. Stored text is rendered as escaped Markdown; raw HTML is
not trusted.

## 5. Incident-to-Kanboard mapping

### 5.1 Domain record

```ts
type IncidentPriority = "P0" | "P1" | "P2" | "P3";
type IncidentState =
  | "filed"
  | "dispatching"
  | "running"
  | "needs-attention"
  | "resolved";
type DispatchState =
  | "starting"
  | "running"
  | "resolved"
  | "needs-attention"
  | "rate-limited"
  | "timed-out"
  | "engine-down"
  | "interrupted"
  | "invalid-result";

type Incident = {
  id: string;
  kanboardTaskId: number;
  title: string;
  description: string;
  priority: IncidentPriority;
  state: IncidentState;
  active: boolean;
  createdAt: string;
  updatedAt: string;
  resolvedAt: string | null;
  dispatch: IncidentDispatch;
  activity: IncidentActivity[];
  coverage: { stale: boolean; detail?: string };
};
```

Kanboard fields carry user-visible task data:

- `kanboardTaskId` comes from task `id`.
- `title` comes from task `title`.
- `description` comes from task `description`, excluding the immutable marker
  when rendered.
- `priority` comes from task `priority` and the matching priority swimlane.
- `state` comes from the task column, cross-checked against dispatch metadata.
- `active` comes from task `is_active`.
- `createdAt`, `updatedAt`, and `resolvedAt` come from `date_creation`,
  `date_modification`, and `date_completed`, converted from Unix seconds.

Priority is deliberately represented twice in the one Kanboard task: native
`priority` makes Kanboard queries and integrations correct; the matching
swimlane makes board structure explicit. The mapping is `P0 -> 0`, `P1 -> 1`,
`P2 -> 2`, `P3 -> 3`. A mismatched numeric priority and swimlane is structural
drift, not a value to guess from. The list shows a coverage warning and the
filing route refuses to dispatch into drifted bootstrap structure.

### 5.2 Metadata

Kanboard task metadata values are strings. Save these names together through
`saveTaskMetadata`:

- `overdeck.schema`: exact `incident/v1`.
- `overdeck.incident_id`: immutable UUID; equals filing `requestId`.
- `overdeck.request_sha256`: lowercase hex SHA-256 of the canonical validated
  request.
- `overdeck.dispatch_id`: immutable UUID for the one attempt.
- `overdeck.cli`: selected registered CLI id.
- `overdeck.model`: selected UI model id.
- `overdeck.wrapper_model`: exact model string passed to the wrapper.
- `overdeck.reasoning_effort`: selected effort id.
- `overdeck.account`: selected account slug actually billed.
- `overdeck.unsafe`: exact `0` or `1`.
- `overdeck.priority`: exact `P0`–`P3`.
- `overdeck.dispatch_state`: one `DispatchState` value.
- `overdeck.status_revision`: base-10 monotonic positive integer.
- `overdeck.started_at`, `overdeck.heartbeat_at`, and `overdeck.completed_at`:
  ISO-8601 or empty string.
- `overdeck.exit_code`: base-10 wrapper exit code or empty string.
- `overdeck.failure_class`: normalized failure class or empty string.
- `overdeck.continuity_id`: provider session/thread id or empty string.
- `overdeck.result_summary`: escaped terminal summary, at most 2,000 characters.

No token, credential, environment value, prompt copy, raw transcript, absolute
path, pid, or systemd property belongs in task metadata. The description is the
operator's prompt source; metadata does not duplicate it.

The description created in the first `createTask` call ends with an immutable
plain-text marker `Overdeck-Incident: <incident-id>`. This is visible in raw
Kanboard data, stripped only from the Overdeck presentation, and makes a task
recoverable after a crash between task creation, reference update, and metadata
save. `updateTask` sets the Kanboard task `reference` to the incident id after
creation. Never create a replacement task to repair a partial filing.

### 5.3 State mapping

- **Filed:** metadata absent, or `starting` before launch admission; active.
- **Dispatching:** `starting`; active.
- **Running:** `running` with a fresh heartbeat; active.
- **Needs attention:** any non-success terminal state, invalid result, or
  interrupted/stale run; active.
- **Resolved:** `resolved` with a valid structured result; inactive after
  `closeTask`.

Column and metadata disagreement renders `needs-attention` with a named
consistency warning. Do not infer resolution from column position, `closeTask`,
exit code zero, a missing process, or a stale heartbeat alone.

## 6. Agent dispatch contract

### 6.1 Option authority

`GET /incidents/options` is derived server-side from the deployed harness
adapter registry plus a versioned incident-dispatch capability manifest. Do not
scrape wrapper source, split model ids on dashes, or invent choices from recent
sessions.

```ts
type IncidentDispatchCapability = {
  cli: string;
  wrapper: string;
  wrapperContract: "incident-wrapper/v1";
  models: Array<{
    id: string;
    efforts: Array<{ id: string; wrapperModel: string }>;
  }>;
  accountMode:
    | { kind: "profile"; provider: string }
    | { kind: "fixed"; account: string };
  permissionModes: Array<"safe" | "unsafe">;
  timeoutSeconds: number;
};

type IncidentDispatchOptions = {
  clis: Array<{
    id: string;
    label: string;
    models: Array<{ id: string; efforts: string[] }>;
    accounts: Array<{
      slug: string;
      label: string;
      ready: boolean;
      fixed: boolean;
    }>;
    permissionModes: Array<"safe" | "unsafe">;
  }>;
  priorities: Array<{ id: IncidentPriority; label: string }>;
};
```

Every `(cli, model, reasoningEffort)` triple resolves to exactly one registered
`wrapperModel`. Unregistered triples are absent and rejected at submission.
`default` is a valid effort id only for a CLI whose provider exposes no effort
control; it means an explicitly registered provider default, not an omitted
accidental default.

For `accountMode:"profile"`, list fresh account slugs for that provider and pass
the selected slug as wrapper `--profile`. For `accountMode:"fixed"`, present
that exact billed account as the single, explicit choice and pass no ignored
profile flag. A selected account must therefore always equal the credential
actually billed. Stale, capped, or unhealthy accounts remain visible with
`ready:false` but cannot be submitted.

The current harness registry does not carry the model/effort, account-mode, or
permission-mode data above. Adding this versioned capability manifest is an
implementation prerequisite. It is not permitted to infer that `ca.sh`,
`grok.sh`, `na.sh`, or `opencode.sh` routes a selected account: those wrappers
currently declare `--profile` ignored. Only capabilities proven by the deployed
wrapper's contract may be offered.

An offered wrapper must implement the base WRAPPER-CONTRACT plus this mandatory
additive flag:

```text
<wrapper> --workspace <dir> --trust <prompt> --task-slug <slug>
          --model <wrapper-model> --timeout <seconds>
          [--profile <account>] --permission-mode <safe|unsafe>
```

This exact shape is `incident-wrapper/v1`. Missing, empty, or unknown permission
mode exits `2` and dispatches nothing. `safe` must select a provider invocation
that enforces approvals/sandboxing; `unsafe` must select the provider's
registered bypass/trust invocation. A wrapper must never ignore the flag,
silently default it, or claim both modes when its run lines are identical.
Existing wrappers are not incident-capable merely because they accept the base
flags: they become eligible only after their manifest declares
`incident-wrapper/v1` and a contract test proves every declared permission mode.
This is the precise wrapper-contract extension needed by the Unsafe control; no
arbitrary provider flags cross the IncidentService boundary.

### 6.2 Unsafe is an enforced permission mode

`Unsafe` defaults unchecked every time the filing drawer opens and is never
remembered. Unchecked means the selected capability must enforce its `safe` mode
at the CLI boundary. Checked means the operator authorizes that capability's
registered `unsafe` mode for this attempt. It does not weaken schema validation,
account caps, timeout, workspace isolation, or the prohibition on automatic
relaunch.

Many current wrappers invoke their engines with trust/bypass flags and therefore
support only `unsafe` until they gain a separately tested safe mode. The options
response must say so. Submitting one while `unsafe:false` returns
`409 unsupported-permission-mode`; silently running an unsafe wrapper under an
unchecked box is a release blocker. The checkbox is never cosmetic prompt text.

### 6.3 Filing and launch

The internal seams are:

```ts
interface IncidentStore {
  file(request: FileIncidentRequest): Promise<StoredIncident>;
  get(incidentId: string): Promise<Incident>;
  list(query: IncidentQuery): Promise<IncidentPage>;
  recordStatus(update: IncidentStatusUpdate): Promise<void>;
}

interface IncidentDispatchLauncher {
  launch(request: IncidentAgentRequest): Promise<IncidentLaunchReceipt>;
}

type IncidentAgentRequest = {
  incidentId: string;
  kanboardTaskId: number;
  dispatchId: string;
  workspace: string;
  wrapper: string;
  wrapperModel: string;
  reasoningEffort: string;
  account: string;
  accountMode: "profile" | "fixed";
  permissionMode: "safe" | "unsafe";
  priority: IncidentPriority;
  timeoutSeconds: number;
};

type IncidentLaunchReceipt = {
  dispatchId: string;
  unit: string;
  acceptedAt: string;
};
```

`workspace`, wrapper path, timeout, and capability resolution are
server-derived. They are never accepted from the browser. The workspace is a
short, incident-specific worktree provisioned below a configured incident
workspace root; one incident never shares a writable worktree with another.
Provisioning failure moves the existing task to Needs attention and launches
nothing.

The filing sequence is fixed:

1. Validate the body and revalidate the selected capability/account against
   current options. The one active IncidentService serializes filing by
   `requestId` before lookup; deploying concurrent incident writers is
   forbidden until a durable distributed reservation contract is added.
2. Resolve an existing task by idempotency key or create one in Filed with
   description marker, priority, and swimlane in the initial `createTask` call.
3. Set its reference and complete `incident/v1` metadata. If either fails, keep
   the task, record a visible failure when possible, return non-2xx, and launch
   nothing.
4. Provision the isolated workspace.
5. Save `starting` revision 1 and move to Dispatching.
6. Start one transient user unit named `overdeck-incident-<incident-id>.service`
   and wait for its exec-admission receipt.
7. Return `201` only after that receipt. A start refusal records Needs attention
   and returns non-2xx.

The transient unit has `Restart=no`, a hard runtime deadline equal to
`timeoutSeconds` plus bounded teardown grace, and one foreground process. It may
use systemd for resource isolation and exit evidence; systemd is not an incident
coordinator and never retries or reconciles the attempt. No timer, collector
poll, page load, Kanboard webhook, or service restart may launch it again. A new
attempt requires a future explicit operator action outside this v1 scope.

The incident runner invokes the registered engine wrapper by path using the
existing WRAPPER-CONTRACT flags (`--workspace`, `--trust`, `--task-slug`,
`--model`, `--timeout`, and `--profile` only for profile-routing wrappers) and
required `--permission-mode <safe|unsafe>` from `incident-wrapper/v1`. The
prompt is built from the stored title and description and ends with the exact
result contract below. The runner remains attached to the wrapper until exit and
delivers that exit into its own terminal status write. It never backgrounds the
engine, starts `runplan`, creates a harness logical run, retries, reviews,
lands, pushes, or merges.

### 6.4 Result and status flow

Agent completion requires one final top-level object:

```ts
type IncidentAgentResult = {
  kind: "incident.result";
  disposition: "resolved" | "needs-attention";
  summary: string;
};
```

`summary` is required, non-empty, and at most 2,000 characters after trimming.
Exit `0` plus a valid `resolved` result is the only automatic resolution path.
Exit `0` with no valid result becomes `invalid-result`; a `needs-attention`
result stays open. A nonzero wrapper exit always stays open, even if output
contains `resolved`.

The runner writes status in this order for each revision: save all metadata for
that revision, move the task to the corresponding column, then create one
human-readable comment. Metadata is the first write because UI truth must not
advance on a decorative comment. Comment reference is
`overdeck:<dispatch-id>:<revision>` on the comment's first line; before
`createComment`, the store scans `getAllComments` for that exact first line, so
a bounded transport retry does not create a duplicate lifecycle comment.

- **Wrapper admitted:** set `running` with start and heartbeat; move to Running.
- **Heartbeat every 15 seconds:** advance the heartbeat; add no comment and do
  not move the task.
- **Valid `resolved`, exit 0:** set `resolved` with completion, result, and
  exit; move to Resolved, then call `closeTask`.
- **Valid `needs-attention`, exit 0:** set `needs-attention` with completion,
  result, and exit; move to Needs attention.
- **Exit 75:** set `rate-limited` with completion, exit, and failure; move to
  Needs attention with no automatic park or retry.
- **Exit 124 or unit deadline:** set `timed-out` with completion, exit, and
  failure; move to Needs attention.
- **Wrapper precondition/down:** set `engine-down` with completion, exit, and
  failure; move to Needs attention.
- **Malformed or missing result:** set `invalid-result` with completion and
  exit; move to Needs attention.
- **Runner termination or stale evidence:** set `interrupted` when a terminal
  write is possible; move to Needs attention.

Status writes use a monotonic revision and are idempotent: repeating the same
revision and values is a no-op; a lower revision is rejected; the one runner is
the sole writer of dispatch-owned keys. Kanboard JSON-RPC has no cross-procedure
transaction, so a projection write may partially fail. The runner retries only
the same idempotent status revision with bounded backoff. Exhaustion exits
nonzero and leaves the last confirmed Kanboard state intact.

A `running` heartbeat older than 45 seconds does not become resolved or failed.
The adapter projects `needs-attention` with “agent heartbeat stale” and names
the last confirmed timestamp. It may inspect the exact transient unit for
supporting evidence, but observation never restarts work and never overwrites
Kanboard solely from process liveness. If Kanboard is unreachable, retain the
last-good incident snapshot, mark the source stale, and do not show an empty
list.

This contract follows the canonical harness reliability decision: one bounded
foreground process, no automatic coordinator relaunch, no observer execution
authority, and terminal uncertainty shown as uncertainty rather than success.

## 7. UI composition

Route: `/incidents`, added to the existing deck shell navigation. This is a
responsive list/detail operations page, not a kanban board.

### 7.1 List

Compose only current `@overdeck/deck-ui` exports:

- `SectionCard` for the page's active and resolved sections;
- `FilterInput` for title/description search;
- `Button` for `File incident` and retrying failed reads;
- `DeckTable`, `DeckTableRow`, and `SortableHeaderCell` for the incident list;
- `StatusChip` for dispatch state using the existing category mapping and an
  explicit text label;
- `StaleBadge` plus `DataCoveragePanel` when Kanboard or bootstrap structure is
  stale/degraded;
- `ActionsMenu` for the row's `View details` action;
- `useDeckTooltip` for absolute ISO time on relative age and update labels;
- `DetailDrawer` for both detail and filing flows, never a hand-rolled modal.

The table columns are `Incident`, `Priority`, `Status`, `CLI / model`,
`Account`, `Opened`, and `Updated`. Incident, Priority, Status, and Updated have
responsive priority 0; the rest progressively hide through
`DeckTableColumn.priority`. Priority is plain `P0`–`P3` text, not a hand-made
chip. Default sort is active first, then P0→P3, then newest update. Operator
sort is title, priority, status, opened, or updated. Relative timestamps use
`formatRelativeTime`; absent timestamps render an em dash, never “now”.

The active view includes Filed, Dispatching, Running, and Needs attention.
Resolved is a separate collapsed section loaded from closed tasks. Search and
priority filters apply to both. Empty active state says “No active incidents.”
Store-down and schema-drift states never reuse that copy.

### 7.2 Detail drawer

Compose:

- `DetailDrawer` with incident id eyebrow and task title;
- `StatusChip` beside the title;
- `KvPanel` for Priority, Kanboard task id, CLI, model, reasoning effort,
  account, permission mode, created/started/completed times, continuity id, exit
  code, and failure class;
- `SectionCard` for the operator description and terminal summary;
- `DeckTable` for recorded lifecycle comments/activity, ordered by revision and
  time;
- `DataCoveragePanel` for missing metadata, stale heartbeat, partial JSON-RPC
  response, or structural drift.

Never render raw metadata keys, credentials, wrapper paths, absolute paths,
process ids, or raw transcripts. The activity table shows only recorded
comments/transitions; it does not synthesize turns from heartbeat intervals.

### 7.3 Filing drawer and the existing-registry gate

The filing drawer is a real form with these exact labels and order:

1. `Title`
2. `Description`
3. `CLI`
4. `Model`
5. `Reasoning effort`
6. `Account`
7. `Unsafe`
8. `Priority`

CLI selection narrows Model, Reasoning effort, and Account from the current
options response. Changing an upstream choice clears an invalid downstream
choice; it never silently substitutes a default. Unsafe starts unchecked.
Priority defaults to P2. The submit button reads `File and dispatch`; while
submitting it is disabled and reads `Filing…`. Keep all input and show the exact
error on failure. On success, close the filing drawer, select the returned
incident, and render only the returned authoritative state—no fabricated
optimistic Running state.

`DetailDrawer`, `SectionCard`, `Button`, and `DataCoveragePanel` cover the
filing shell, actions, and error surface. The current `@overdeck/deck-ui` barrel
has no general text field, textarea, select, or checkbox export. `FilterInput`
is specifically a search control; `AgentComposer`, `DecisionRow`, and
`RateLimitDialog` have different domain contracts and must not be repurposed.
Therefore UI implementation is blocked by the repository's shared-component-only
law until the owner explicitly approves appropriate form primitives. Do not
hand-roll these controls in `apps/web`, and do not name or create unapproved
primitives from this spec. After approval, each primitive must be token-only,
work in both themes, have a colocated test, be exported, and be registered with
all states in `/design-system` in the same change.

### 7.4 Refresh and accessibility

The collector polls Kanboard every five seconds and emits incident panel changes
through its existing SSE channel. Filing response data seeds the selected
detail, then normal polling owns refresh. Polling failure retains last-good data
and marks it stale.

The filing form has programmatic labels, field-level errors linked with
`aria-describedby`, an `aria-live` submit error, and focus on the first invalid
field. The unsafe label includes visible copy explaining the enforced permission
change; color is never its only warning. Drawer Escape, focus trap, close focus
restoration, table sort `aria-sort`, and ≥44 px control targets come from the
named deck-ui components. Both themes and keyboard-only filing are acceptance
requirements.

## 8. Security and failure invariants

- Kanboard is the sole incident store. No collector SQLite table, JSONL mirror,
  localStorage queue, or browser cache owns incident state.
- Secrets remain server-side and mode `0600`. Redact Basic auth, database URLs,
  account homes, and environment blocks from errors and logs.
- All browser paths, Kanboard procedures, CLI ids, model/effort triples,
  accounts, priorities, and permission modes are allowlisted and
  runtime-validated.
- Command invocation uses argv arrays and server-derived paths. Incident text is
  data, never shell.
- One filing id maps to one Kanboard task and one dispatch id. Partial failure
  repairs that task; it never creates a substitute.
- No automatic retry, fallback account, model substitution, permission
  escalation, wrapper relaunch, task deletion, or incident resolution.
- Priority changes presentation only in v1. It does not bypass concurrency,
  caps, safety, or timeout.
- Kanboard/API unavailability is visible and stale; it never resolves incidents
  or renders an empty healthy page.
- Existing Overdeck and source tools continue operating if Kanboard or
  `/incidents` is down.

## 9. Implementation and acceptance gates

Implementation order is contract-first:

1. deployment and real Kanboard v1.2.52/PostgreSQL contract fixture;
2. closed JSON-RPC client, bootstrap, mapping, and idempotent filing tests;
3. versioned dispatch capability manifest and options projection;
4. deterministic local incident-runner fixture and all lifecycle/failure cases;
5. collector routes and same-origin proxy;
6. owner approval plus deck-ui form primitives;
7. page composition, both-theme and browser verification;
8. packaging, backup/restore drill, and loopback smoke.

Required automated cases:

- bootstrap absent, already correct, missing one column/lane, unknown structural
  drift, and no task ever created in default swimlane;
- create maps P0–P3 to both numeric priority and exact swimlane;
- same request id/body returns one task and one dispatch; changed body returns
  409; crash-window task is recovered from its immutable marker;
- malformed, missing, mismatched-id, partial-batch, `false`, and JSON-RPC-error
  responses retain last-good state and surface failure;
- invalid CLI/model/effort/account/permission combinations launch nothing;
- unchecked Unsafe cannot invoke an unsafe-only wrapper; checked Unsafe is
  recorded exactly once;
- one deterministic, provider-free wrapper covers running heartbeat, valid
  resolve, valid needs-attention, exit 75, exit 124, engine-down, invalid
  result, stale heartbeat, terminal Kanboard outage, and no automatic relaunch;
- exit zero without `incident.result` never closes a task; nonzero plus
  `resolved` never closes it;
- list/detail use only real Kanboard data, resolved tasks come from closed
  tasks, timestamps remain honest, and stale source is not empty state;
- filing retains values on error, clears invalid dependent selections, defaults
  Unsafe off/Priority P2, is keyboard-complete, and renders in both themes;
- no Kanboard token/address or account credential appears in browser assets,
  network payloads, or logged errors.

Run the repository's real gates for every touched module. UI implementation
requires:

```text
pnpm --filter @overdeck/deck-ui test
pnpm --filter @overdeck/deck-ui typecheck
pnpm --filter web build
pnpm --filter web typecheck
```

Collector implementation requires its full Bun test suite. Deployment acceptance
additionally starts the pinned real containers, files an incident through
Overdeck, observes the Kanboard task in the correct column/swimlane, completes
the deterministic runner, observes the closed Resolved task, restarts collector
and web, and confirms the same incident remains visible without another agent
start. Automated acceptance must consume no provider account or model quota.

## 10. External contract references

- [Kanboard API overview and JSON-RPC security model](https://docs.kanboard.org/v1/api/)
- [API authentication and `/jsonrpc.php`](https://docs.kanboard.org/v1/api/authentication/)
- [Task procedures](https://docs.kanboard.org/v1/api/task_procedures/)
- [Task metadata procedures](https://docs.kanboard.org/v1/api/task_metadata_procedures/)
- [Swimlane procedures](https://docs.kanboard.org/v1/api/swimlane_procedures/)
- [Column procedures](https://docs.kanboard.org/v1/api/column_procedures/)
- [Comment procedures](https://docs.kanboard.org/v1/api/comment_procedures/)
- [Official container guidance](https://docs.kanboard.org/v1/admin/docker/)
- [Database requirements](https://docs.kanboard.org/v1/admin/requirements/)
- [Kanboard v1.2.52 release](https://github.com/kanboard/kanboard/releases/tag/v1.2.52)
- `docs/specs/2026-07-18-overdeck-app-spec.md` — browser/collector security and
  federation
- `docs/specs/2026-07-30-harness-reliability-lessons-and-plan.md` — foreground,
  completion, no-relaunch, and projection invariants
- `modules/harness/spec/WRAPPER-CONTRACT.md` — engine wrapper invocation and
  exit codes
- `.claude/skills/od-ui-dev/SKILL.md` — shared UI registry, token, gallery,
  a11y, and honest-data rules

## 11. Implementation status and unresolved conflicts

Recorded 2026-08-08. Sections 1–10 above stay canonical. This section records
what is built, what is blocked, and where later settled decisions contradict the
spec. Re-read it before planning further incidents work — do not rediscover these
findings.

### 11.1 Built

- §9 step 1 — Kanboard deployed, loopback `127.0.0.1:31339`.
- §9 step 2 — closed JSON-RPC client, bootstrap, and domain mapping, with tests
  (`collector/src/incidents/`). Idempotent *filing* is NOT built; nothing writes
  a task yet.
- §9 step 5 — READ routes only: `GET /incidents`, `GET /incidents/:id`, plus the
  same-origin proxy allowlist. No mutating route exists.
- §9 step 6 — owner approved extraction; `TextField`, `TextArea`, `Select`,
  `Checkbox` shipped in `@overdeck/deck-ui`, gallery-registered, all inline
  controls migrated.
- §7.1 list and §7.2 detail drawer, read-only.

### 11.2 Blocked — dispatch capability manifest is absent

§9 step 3 is unimplemented, so §6.1 options projection, §6.3 filing and launch,
and §7.3 filing drawer CANNOT be built as specified.

Measured on engine bundle `0.1.129`
(`~/.harness/engine/versions/<CURRENT>/presets/adapters.json`):

- registry declares `adapters/v1`; no entry declares `incident-wrapper/v1`;
- `modules/harness/spec/adapters.schema.json` sets `additionalProperties: false`
  with no field for capability/contract, account mode, or permission mode;
- `incident-wrapper/v1` occurs in THIS spec only — no wrapper implements it.

This matches §6.1's own statement that the versioned capability manifest is an
implementation prerequisite.

Unblock order — MUST be done before any filing UI:

1. add the capability-manifest fields to `adapters.schema.json`;
2. declare them per wrapper in `presets/adapters.json`;
3. release the engine (`bin/harness-release.sh bump`) — the repo copy has NO
   runtime effect until bumped;
4. then build §6.1 options projection, §6.3 launch, §7.3 drawer.

Do NOT ship a filing form before step 3. With zero declared wrappers the CLI
select has no options, submit can never succeed, and the result is a dead button.

### 11.3 Conflicts between this spec and later settled decisions

Rule: **this spec governs.** A settled decision that contradicts it is amended
below, not silently applied.

**C1 — `Triage` column does not exist. RESOLVED in favour of spec.**
A settled decision says detected fires "file into Triage inert". §3.3 fixes the
first column as `Filed`; bootstrap provisions `Filed`, `Dispatching`, `Running`,
`Needs attention`, `Resolved`. Read `Triage` as `Filed` wherever it appears.
NEVER add a `Triage` column.

**C2 — auto-filing detected fires is OUT OF SCOPE. UNRESOLVED, spec governs.**
§1 excludes importing or deduplicating source alerts into Kanboard; it is a
separate design. Any auto-file-on-detection behaviour needs its own spec and
owner decision. Do NOT implement it under this spec.

**C3 — no `gnome-terminal`, no Dispatch/Dismiss notification workflow.
RESOLVED in favour of spec.**
A one-notification-per-incident Dispatch/Dismiss flow and a `gnome-terminal`
launch appear nowhere in §6.3. §6.3 mandates a transient user unit
`overdeck-incident-<incident-id>.service`, `Restart=no`, one foreground process,
no automatic relaunch. Build that. NEVER launch a terminal emulator, and never
put a dispatch decision behind a desktop notification.

**C4 — §7.3's "blocked until owner approves form primitives" is now stale.**
The owner approved extraction and the primitives shipped. §7.3 remains blocked,
but on C-11.2 (capability manifest), not on primitives.

### 11.4 Landmine for whoever adds `GET /incidents/options`

Route ordering is load-bearing. `collector/src/server.ts` matches incident detail
with `/^\/incidents\/([^/]+)$/`, and the web proxy allows `^incidents\/[^/]+$`.
Both already match `incidents/options`. Register the `options` route BEFORE the
detail regex, or `options` is parsed as an incident id and returns 404.
