# Overdeck Application Spec

Audience: AI coding agents first.

## Authority

Treat this document as source of truth for Overdeck product scope, domain contracts, boundaries, and invariants. Treat `collector/src/schema.ts` as wire-schema implementation authority. Treat source tools as authority for their own state. Treat plans as implementation sequencing only; when plan intent conflicts with this spec, this spec wins.

## Product contract

Overdeck MUST provide one local observation deck for AI operations: runs, CI, rate limits and spend, human decisions, alerts, gates, project progress, build/offload, agents, bots, and host health.

Primary journey: open Overview and determine within 10 seconds:

- what is broken;
- what needs operator input;
- how far each project is from done;
- which allowlisted action can resolve an actionable condition.

Daily-driver adoption is product metric. Overdeck succeeds when operator stops opening systray, `gh`, journals, and Grafana separately for routine triage. If Overdeck is not daily driver within two weeks of v1 Overview/Inbox/Decisions availability, freeze further investment per `GOLIVE.md`.

Apply **federation**:

- Keep harness control API, systray-ai `HealthStore`, Prometheus/node_exporter, agent-guard, `gh`, security-gate/slopgate, Botmaster, GOLIVE files, and build-offload controller authoritative.
- Read each source through one adapter.
- Normalize source state into `Item` and `Panel` snapshots.
- Route mutations through typed, allowlisted source APIs or commands only.
- NEVER replace, rewrite, or shadow authoritative source state inside Overdeck.
- NEVER require Overdeck for source tools to keep operating.

## Scope

### In scope

- Bun collector: federated polling, normalization, reconciliation, local journal, bearer-authenticated HTTP/SSE read API, digest, and action gateway.
- Astro web deck: Node standalone SSR, React islands, shared deck components, same-origin collector proxy, dark/light local preference.
- Surfaces: Overview, Inbox, Decisions, Map, Plans, Agents, CI & Build, Bots, Limits & Spend, Gates, Scoreboard, Settings.
- Operator actor: `dashboard-operator`. This is one local operator using one server-held collector credential. No application identity or role model exists.
- Desktop notifications for `severity:"act"` items and template-based morning digest.

### Non-goals

- Public or hosted service; multi-user access; RBAC; mobile app; push notifications.
- General CI system, scheduler, metrics store, historical warehouse, or source-tool replacement.
- LLM-generated insights or digests.
- Editing plans or GOLIVE files from UI.
- Mutating offload files, runner labels, services, or controller state directly.
- Treating diagnostic logs as authoritative state.
- New chart framework without separately proven need.

## Runtime topology

```text
authoritative tools/files/APIs
          ↓ read-only adapters
Bun collector on 127.0.0.1:4980
  Item/Panel state + SSE + default-deny actions
          ↑ server-held bearer token
Astro SSR same-origin proxy
          ↑ no collector token/address exposed
browser deck
```

- Collector MUST bind `127.0.0.1` by default.
- Tailnet/non-loopback binding MUST require explicit configuration and remain off by default.
- Web app MUST run as Astro Node standalone SSR; server code owns collector address and credential.
- Browser MUST call only same-origin `/api/collector/*` routes.

## Normalized domain model

Preserve identifiers and field names exactly. Validate collector output with these Zod contracts before state reconciliation.

```ts
type Project = {
  id: string;
  name: string;
  color: string;
}

type Severity = "act" | "warn" | "info";

type Kind =
  | "alert"
  | "ci"
  | "halt"
  | "decision"
  | "limit"
  | "gate"
  | "progress"
  | "build";

type ActionRef = {
  verb: string;
  args: Record<string, string>;
  label: string;
  recommended?: boolean;
}

type Decision = {
  question: string;
  options: Array<{
    label: string;
    recommended?: boolean;
  }>;
  freeText: boolean;
  context: string;
  waitingSince: string;
}

type Item = {
  id: string;
  source: string;
  project?: string;
  severity: Severity;
  kind: Kind;
  title: string;
  detail: string;
  ts: string;
  actions: ActionRef[];
  decision?: Decision;
}

type Panel = {
  id: string;
  ts: string;
  data: unknown;
}

type AdapterResult = {
  items: Item[];
  panels: Panel[];
}
```

### Field invariants

- `Item.id` MUST be stable per source condition and serve as dedupe/reconciliation key.
- `Item.source` MUST equal owning adapter ID.
- `Item.project`, when present, MUST reference `Project.id`.
- `Item.ts`, `Panel.ts`, and `Decision.waitingSince` MUST be timestamp strings suitable for age ordering.
- `Item.actions` MUST exist; use `[]` when no action applies.
- `Item.decision` MUST exist for `kind:"decision"`; do not attach incomplete decision payloads.
- `Panel.id` MUST identify one current projection. Dynamic projections MAY use stable qualified IDs such as `host:<hostname>` and `forensics:<runId>`.
- `Panel.data` is intentionally `unknown` at shared wire boundary. Consumers MUST narrow it through panel-specific types before use. Do not claim shared Zod validation for payloads not represented in `PanelSchema`.

### Severity semantics

- `act`: operator action required now; eligible for desktop notification and top-priority Inbox placement.
- `warn`: degraded condition or approaching threshold; operator attention warranted, not immediate intervention.
- `info`: progress or status context; no urgent intervention.

Sort actionable views by severity priority `act`, `warn`, `info`, then newest relevant `ts`. Do not infer severity from color or copy.

## Adapter contract

Every source integration MUST implement:

```ts
interface Adapter {
  readonly id: string;
  readonly interval: number; // milliseconds
  poll(): Promise<AdapterResult>;
}
```

Apply **snapshot reconciliation**, not event accumulation:

- `poll()` MUST return adapter's complete current `items` set plus current `panels` snapshot.
- Missing item ID after a successful poll means resolved. Collector MUST remove it, journal tombstone, and emit `item-resolved`.
- Failed poll MUST retain prior items and panels unchanged; it MUST NOT resolve absent data from an incomplete read.
- Collector MUST isolate adapter failures. One failed source MUST NEVER crash collector or block other adapters.
- Collector status is stale when no successful poll exists or `now - lastSuccess > interval * 2`.
- Scheduler MAY jitter each interval; adapter semantics MUST NOT depend on exact poll timing.
- Register adapter once by unique `id`.

### Adapter registration

- Add one adapter module per authoritative source.
- Configure source location, token-file path, interval, and source-specific thresholds at collector boundary.
- Enable every registered adapter by default when absent from `config.adapters`.
- Omit adapter only when `config.adapters.<id>.enabled = false`.
- Let `intervalMs` override adapter default interval.
- Read token files server-side. NEVER store secret values in config or repo.

Current adapter IDs: `harness`, `systray-ai`, `ghci`, `prometheus`, `golive`, `cluster`, `gates`, `botmaster`, `offload`.

### Source-down and stale modelling

Model expected source unavailability as observable state:

- Transport-unreachable source MUST return one stable source-down `Item` and stale panel projections. It MUST NOT throw.
- Stale panel payload MUST include `stale:true` at panel-specific data boundary.
- Source-down item MUST use stable incident ID, `severity:"act"`, source-appropriate `kind`, and no invented healthy data.
- Recovery MUST return fresh panels without source-down item; normal snapshot reconciliation resolves incident.
- Reachable but non-2xx, malformed, or incomplete authoritative response MUST throw. Collector retains last-good snapshot and records adapter error.
- NEVER convert parse/schema corruption into a source-down transport incident.
- NEVER emit threshold alerts from stale measurements.

Offload controller-down behavior is stricter and canonical in referenced offload control-plane spec below.

## Collector read API

Require `Authorization: Bearer <token>` on every collector route. Reject missing or wrong token with `401`.

- `GET /state` → `{panels, adapters}`.
- `GET /items?kind=` → `{items}`; optional `kind` filters current visible items.
- `GET /events` → SSE deltas: `{type:"item",item}`, `{type:"panel",panel}`, or `{type:"item-resolved",id}`.
- `GET /digest` → deterministic template digest.

**Project config route (collector-owned config mutation — wire schema pinned; route NOT built yet, see Implementation gates):**

- `GET /config/projects` → `{projects: ProjectConfig[]}` where `ProjectConfig = {id: string, label: string, color: string}` (`color` = `#RRGGBB`).
- `POST /config/projects` → body `ProjectConfig` (upsert by `id`; `label`/`color` optional on update, `id` required), returns `{projects: ProjectConfig[]}` (the full updated list). Zod-validate `color` as a hex string; reject unknown fields.
- Persist to a collector-owned file under `~/.config/overdeck/` (mode `0600`). This mutates **collector-local presentation config only** — NEVER a source tool.

Collector MUST return `404` for unrecognized path/method combinations. Port conflict MUST fail startup; NEVER auto-increment.

## Action gateway

Apply **default-deny** and **validate at trust boundary**.

### Decision answer

**Target contract:**

- Accept `POST /decisions/:id/answer` with Zod-validated `{choice:string}`.
- Resolve current decision item by `:id`; reject absent, resolved, or non-decision IDs.
- Proxy answer only through owning adapter's typed decision method — the harness adapter exposes `answerDecision(runId, decisionId, choice)`. The route MUST map the collector item `:id` → `(runId, decisionId)` and `choice` → the option the source accepts.
- Preserve pending item and surface error when source rejects or is unreachable.
- NEVER let browser call harness directly.

**Choice semantics (RESOLVED — normative):** add a stable `value: string` to each decision option (`DecisionSchema.options` becomes `{value, label, recommended?}[]`; `value` is the option id, stable across polls). The request body `{choice: string}` carries **that `value`** for option decisions; for a `freeText:true` decision `choice` carries the raw text. The route MUST reject a `choice` that matches no option `value` (and is not a free-text decision) with `400`. **Do NOT** match on `label` or positional index — labels are display text and can collide/reorder.

**Implementation gates (as of 2026-07-18):**

- **Route NOT built.** `collector/src/server.ts` serves only `/state /items /digest /actions/:verb /events`; there is **no** `/decisions/:id/answer` handler. The harness capability (`answerDecision(runId, decisionId, choice)`) exists but is unexposed. The web proxy already routes this path → it currently reaches a `404`. Building the route MUST map collector item `:id` → `(runId, decisionId)` and forward `choice`.
- **Option `value` NOT in schema yet.** `DecisionSchema.options` is `{label, recommended?}[]` today — the `value` field above is a required schema change; add it in the same task as the route so `choice` has a validated target.

### Action endpoint

Accept `POST /actions/:verb` only for the exact `ALLOWED_ACTION_VERBS` allowlist. Anything else MUST return `404`.

**Live today (`collector/src/actions.ts`, exactly 4):**

```text
reap        # → reaper-ctl kill <pid> --escalate   (ReapArgs: {pid})
ci-rerun    # → gh run rerun <id>                   (CiRerunArgs: {id})
steer       # → harness adapter typed steer method  (SteerArgs: {runId, taskId, text, restart?})
snooze      # → collector-local visibility only     (SnoozeArgs: {itemId, durationMs})
```

**Declared but NOT YET IMPLEMENTED (offload transition verbs — the offload adapter advertises them in item `actions[]`, but they are absent from `ALLOWED_ACTION_VERBS` and therefore 404 today; blocked on control-plane R2 + Overdeck X1):**

```text
box-drain  box-restore  host-quarantine  host-unquarantine
admission-reconcile  job-retry  ci-reconcile  recall-spill
```

- Validate body as `RequestBodySchema = {args: Record<string,string>, requestedBy?: string}` with Zod.
- Validate verb-specific args with a dedicated Zod schema (`ReapArgs`/`CiRerunArgs`/`SteerArgs`/`SnoozeArgs` above; offload verbs add their own when built).
- Revalidate targets against current panels/items: reap PID MUST be current orphan candidate; CI run ID MUST exist in `ci`; offload host/job/PID MUST exist in current offload panels where required.
- Invoke local commands with argv arrays only. NEVER concatenate args into shell command strings.
- `snooze` changes collector-local visibility only; it MUST NOT mutate source item.
- Offload verbs (once live) MUST call the controller transition API only. NEVER shell out to edit config, flock state, GitHub labels, runner services, or source files.
- Journal every accepted success and failure to `actions.jsonl`.
- Keep item visible on action failure; on `httpStatus >= 400` the gateway calls `state.annotateActionError(requestedBy, …)` — i.e. **`requestedBy` is dual-purpose today: journal actor AND the target item id used to annotate the failing row.** Reviewer-flagged wart: a server-derived actor SHOULD be split from an explicit `targetItemId`; document as a hardening gate, do not silently rely on the overload.

Offload transitions additionally MUST carry `expectedRevision` and `idempotencyKey`; stale revision is `409` no-op, replay returns prior result without re-execution, audit-write failure refuses mutation. **The gateway MINTS the `idempotencyKey` (UUID v4) per user action** — the adapter does not supply one. `expectedRevision` crosses as a string and is coerced to number before the controller call. Per-verb `args` schemas and the transition wire contract are pinned in the offload control-plane spec (§ Transition API); this spec does not restate them.

## Web proxy boundary

Use same-origin server-side proxy. Browser MUST NOT receive collector bearer token or collector address.

Read allowlist:

```text
state
items
events
digest
config/projects
```

Mutation allowlist:

```text
decisions/:id/answer
actions/:verb
config/projects
```

**Implementation gate:** the proxy admits `config/projects` on both the read and mutation allowlists, but `collector/src/server.ts` serves **no** `config/projects` route — both currently reach a `404`. The Settings project-color surface is blocked on this collector route being built; the proxy is ahead of the producer.

- Match paths exactly or with anchored route patterns. NEVER implement open passthrough.
- Inject bearer token in SSR route only.
- Read token from `COLLECTOR_TOKEN` or collector token file server-side.
- Forward query string only for allowlisted GET route.
- Preserve SSE content type, no-cache, and keep-alive headers.
- Map unreachable collector to `502 collector unreachable`.
- Add mutating routes with dedicated handlers; NEVER widen read allowlist to enable mutation.

## Security and ownership invariants

- Bind collector and offload control plane to loopback by default.
- Store generated collector token at `~/.config/overdeck/token` with mode `0600`.
- Keep credentials, source addresses, and token paths out of browser bundle and source control.
- Authenticate before route dispatch.
- Treat browser payloads, source responses, config, and panel narrowing as trust boundaries.
- Expose no arbitrary command, arbitrary URL, arbitrary file, or arbitrary proxy primitive.
- Mutate no authoritative source directly. Use allowlisted command/API seam owned by that source.
- Keep action audit fail-closed where authoritative controller contract requires it.
- Do not invent user identity, RBAC, or ownership checks. Product is single-local-operator until a separate identity spec exists.

## Product surfaces

Use approved HTML mockups as visual oracle; use this section for semantic scope.

- Overview: scoreboards, urgent items, limits/spend, run forensics, bots, CI, plans, hosts/gates, first-visit morning digest.
- Inbox: filter by kind, severity ordering, project tags, `ActionRef` controls, explicit confirmation for destructive actions, snooze, inbox-zero state.
- Decisions: pending HALTs, harness decisions, and gate sign-offs; expand in place; option/free-text answer; preserve answer input and row on failure.
- Map: topology derived from panels; live status chips; node inspector. NEVER hardcode fleet membership.
- Plans: runs, waves, HALTs, per-run forensics.
- Agents: fleet, resource state, orphan candidates.
- CI & Build: CI runs/runners plus offload control, global queue, remote jobs, dynamic fleet, incidents, typed actions.
- Bots: Botmaster status, volume, errors, cost.
- Limits & Spend: every systray account, utilization, window, cap ETA, stale state.
- Gates: security-gate findings, slopgate debt/delta, pending sign-offs.
- Scoreboard: GOLIVE works/total, seven-day trend, churn signal; read-only.
- Settings: adapter status/interval visibility and project colors. Project-color writes are collector-owned config mutation only, never source-tool mutation.

Theme preference and Inbox snooze MAY persist in browser local storage because both are operator-local presentation state. They MUST NOT delete or alter collector/source state.

## Offload/CI seam

Reference `/home/user/.claude/docs/specs/2026-07-18-offload-control-plane-spec.md` as sole authority for:

- offload adapter A8 source and snapshot contract;
- exactly four panels: `offload-control`, `cluster-queue`, `remote-jobs`, `fleet`;
- X1 offload action verbs and transition semantics;
- controller-down modelling;
- desired/observed state, revision, fallback lease, queue/admission, capability, jobs, artifact publication, events, and metrics.

Overdeck MUST consume that seam. NEVER duplicate controller policy in app, adapter, UI, or this spec. NEVER parse `local-gate.log`, flock files, or `build-remote.json` as authoritative. NEVER hardcode build hosts.

## Acceptance and lifecycle constraints

`GOLIVE.md` owns finite user-visible acceptance criteria, one-week side-quest budget, adoption metric, and kill threshold. Do not duplicate checkbox status here.

Release claims MUST demonstrate:

- Overview local load under two seconds with no browser console errors.
- Any single source down produces stale/degraded UI without collector crash or false green/false alerts.
- Decision answer and each enabled action traverse browser → same-origin proxy → authenticated collector → typed source seam.
- Non-allowlisted actions and proxy paths remain unreachable.
- Every mutation is journaled and visible as success/failure.
- UI route/element contract remains aligned with `docs/specs/2026-07-18-ui-element-matrix-design.md`.

## Implementation gates — built vs not (as of 2026-07-18)

This spec is the **target** contract. Several surfaces landed with the consumer ahead of the producer — the web proxy / adapters advertise seams the collector does not yet serve. Each row is a real gap the build MUST close; until then the affected surface degrades to `404`/`controller-down`, not a false green.

| Seam | Declared here | Built in collector? | Blocked on |
|------|---------------|---------------------|------------|
| Action verbs | 12 (`reap`…`recall-spill`) | **4** (`reap ci-rerun steer snooze`); 8 offload verbs 404 | control-plane R2 + X1 |
| `POST /decisions/:id/answer` | typed decision proxy (`:id`→`(runId,decisionId)`) | **NO route** in `server.ts` (harness `answerDecision` unexposed) | new collector route |
| Decision option `value` | `choice` targets a stable option id | **NO** — `options` are `{label, recommended?}` only | add `value:string` to `DecisionSchema.options` (same task) |
| `config/projects` (read + write) | schema pinned above; proxied both ways | **NO route** in `server.ts` — 404 | new collector route |
| `requestedBy` | journal actor | overloaded as **actor AND target itemId** | split to server-actor + `targetItemId` |

Offload/CI seam gaps (controller `/status`, `/api/v1/query`, transition API, events) are owned by and enumerated in `/home/user/.claude/docs/specs/2026-07-18-offload-control-plane-spec.md` → *Implementation gates*. This spec does not restate them.

## Source map

- Wire schema: `collector/src/schema.ts`
- Adapter interface and reconciliation: `collector/src/adapter.ts`, `collector/src/collector.ts`, `collector/src/state.ts`, `collector/src/scheduler.ts`
- Adapter registration/config: `collector/src/adapters/index.ts`, `collector/src/config.ts`
- Action gateway: `collector/src/actions.ts`
- Collector HTTP boundary: `collector/src/server.ts`
- Web proxy: `apps/web/src/pages/api/collector/[...path].ts`
- Browser wire mirror: `apps/web/src/lib/collector-types.ts`
- Visual oracles: `docs/mockups/*.html`
- Actor/outcome evidence: `docs/user_journeys/*`
- Product budget and acceptance: `GOLIVE.md`
- Implementation intent/history: `docs/plans/2026-07-17-overdeck-v1.md`
