audience: AI coding agents first.

# Incident dispatch selector options

## Result

Make File incident dispatch selectors work before any incidents exist. Populate CLI-scoped Model, Reasoning effort, and Account choices from deployed harness capabilities. Offer only CLIs that satisfy `incident-wrapper/v1`; initial manifest registers `codex` only. NEVER derive choices from incident history. NEVER submit or dispatch an unregistered CLI/model/effort/account combination.

Plan slug: `incident-dispatch-selector-options`

Canonical parent contract: `docs/specs/2026-08-08-incidents-page-design.md` §6. This document narrows implementation to selector authority, cascading UI state, and trust-boundary validation. Parent contract wins on unrelated incident behavior.

## Current defect

- `apps/web/src/components/incidents/incident-view.ts` derives Model and Account from `Incident[]`.
- Empty or new stores therefore expose only `None`.
- `apps/web/src/components/incidents/FileIncidentForm.tsx` submits empty values while `collector/src/server.ts` requires non-empty `cli`, `model`, `reasoningEffort`, and `account`.
- `GET /incidents/options` returns taxonomy `types` only, despite parent contract requiring dispatch options.
- Seeded UI tests conceal empty-store behavior.

## Scope

MUST:

1. Add versioned incident-dispatch capability manifest beside deployed incident assets.
2. Extend `modules/harness/wrappers/codex.sh` to honor required `--permission-mode safe|unsafe`; safe MUST omit bypass flags, unsafe MUST preserve explicit bypass behavior, and unknown/missing mode MUST exit `2` before launch.
3. Join manifest with deployed harness adapter registry and live provider account registries in collector.
4. Return taxonomy plus complete CLI-scoped dispatch options from `GET /incidents/options`.
5. Drive all dispatch selectors from that response.
6. Reset dependent selections when upstream selection changes.
7. Disable submission until one registered dispatch combination is selected.
8. Validate combination again in collector before any incident write and immediately before dispatch.
9. Cover empty-store, unavailable-source, stale-selection, tampered-request, and wrapper permission-mode paths.

MUST NOT:

- infer options from incidents, wrapper source text, model-id parsing, or UI constants;
- offer account profile selection to wrappers that ignore `--profile`;
- mark unknown capability or account data usable;
- invent fallback options when capability loading fails;
- add new UI primitives.

Out of scope:

- incident table behavior;
- taxonomy suggestion behavior;
- adding Claude, Fable, Cursor, `xhigh`, or `max` dispatch capabilities; those remain absent until their adapter, identity, and permission contracts are authoritative;
- account health redesign; v1 sets `ready:true` for every account present in the selected provider registry. Registration is the only current authority. Source-read failure makes all options unavailable; NEVER invent another health parser.

## Authority and contracts

### Deployed inputs

Use three authorities. Keep responsibilities separate.

1. `modules/harness/presets/adapters.json`
   - owns adapter id, wrapper path, and executable wrapper-model ids;
   - capability manifest MUST reference those wrapper-model ids verbatim. It MUST NOT construct or independently invent them.
2. `modules/workstation/claude/incidents/dispatch-capabilities.json`
   - owns incident-specific CLI id, adapter binding, effort mapping, account mode, permission modes, and wrapper contract version;
   - deploys with existing incident assets.
3. runtime provider registries under `OVERDECK_SYSTRAY_RUNTIME_DIR`
   - own current account slugs for profile-selectable providers;
   - use existing routing-config account reader semantics; do not parse registry shape twice.

### Capability manifest seam

```ts
type IncidentDispatchCapabilityManifest = {
  version: "incident-dispatch-capabilities/v1";
  capabilities: Array<{
    cli: string;
    label: string;
    adapter: string;
    wrapperContract: "incident-wrapper/v1";
    models: Array<{
      id: string;
      efforts: Array<{ id: string; wrapperModel: string }>;
    }>;
    accountMode:
      | { kind: "profile"; provider: "codex" | "claude" }
      | { kind: "fixed"; account: string; label: string };
    permissionModes: Array<"safe" | "unsafe">;
  }>;
};
```

Validation MUST fail closed when:

- version or shape is unknown;
- `cli`, `adapter`, model id, effort id, or `wrapperModel` duplicates within its scope;
- adapter is absent from `adapters.json`;
- any capability `wrapperModel` is absent from bound adapter `models`;
- declared wrapper differs from adapter registry ownership;
- joined CLI has zero models, effort mappings, accounts, or permission modes;
- fixed account is empty;
- profile provider registry cannot be read or parsed.

Every offered `(cli, model, reasoningEffort)` MUST map to exactly one `wrapperModel`. Preserve manifest array order through serialization; model and effort order is presentation order, never alphabetical sorting.

### Initial capability matrix

Ship one proven CLI. Do not expand scope to reconcile unrelated model catalogs.

| CLI | Adapter | Logical models | Efforts | Wrapper model mapping | Account | Permissions |
|---|---|---|---|---|---|---|
| `codex` | `codex` | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` | `low`, `medium`, `high` | manifest explicitly enumerates all nine mappings to existing fused adapter ids; loader MUST NOT construct ids by concatenation | `{ kind:"profile", provider:"codex" }` | `safe`, `unsafe` |

`xhigh` and `max` MUST NOT be offered. Current adapter registry does not register those fused wrapper models; owner policy also forbids Codex `xhigh`. `claude` and `cursor-agent` MUST NOT be offered in v1 because their wrappers ignore profile selection, lack an incident-safe permission mode, or lack an authoritative fixed billed-identity source. Add them only in a later manifest revision after their contracts and tests satisfy parent spec §6.

### API response seam

Extend, do not replace, current taxonomy response.

```ts
type IncidentOptionsResponse = {
  types: IncidentTypeOption[];
  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">;
  }>;
};
```

`wrapperModel` remains collector-internal authority. API exposes effort ids only; submission validation resolves the registered wrapper model server-side.

`GET /incidents/options` behavior:

- `200`: taxonomy and complete validated dispatch options;
- `503 incident-assets-unavailable`: taxonomy, adapter registry, or capability manifest missing/invalid;
- `503 incident-accounts-unavailable`: required profile account registry missing/invalid.

Never return a partial `200`. Partial option authority produces invalid submissions.

### Submission seam

Extend file-incident request validation from non-empty strings to registered-combination validation.

```text
validateIncidentDispatchSelection({ cli, model, reasoningEffort, account, unsafe }, options): validated selection
```

Required behavior:

- load all four authorities fresh for every POST; NEVER validate against options cached by or previously served to the client;
- reject unknown CLI, model, effort, or account with `400 invalid-incident-dispatch`;
- reject `unsafe:true` unless selected CLI declares `unsafe`;
- reject `ready:false` account;
- resolve and persist authoritative `wrapperModel` server-side;
- accept no client-supplied wrapper-model mapping;
- perform validation before Kanboard mutation or idempotency record creation;
- reload and revalidate persisted selection immediately before `dispatchIncident` launches work; if any dimension is no longer registered, refuse dispatch with named `incident-dispatch-options-stale` and leave incident recoverable.

## Components

### 1. Collector option loader

Create one collector incidents module responsible for loading and validating adapter registry, capability manifest, taxonomy, and provider accounts. Expose one injected dependency seam for route and service tests.

```text
loadIncidentOptions(): LoadedIncidentOptions
serializeIncidentOptions(options): IncidentOptionsResponse
validateIncidentDispatchSelection(request, options): ValidatedIncidentDispatchSelection
```

`LoadedIncidentOptions` retains `wrapperModel` mappings and source provenance internally. `serializeIncidentOptions` strips internal mappings from HTTP output.

Keep filesystem paths in `collector/src/paths.ts`. Keep schemas and joins in collector incidents module. Route handler only translates typed outcomes to HTTP.

### 2. Incident options route

`collector/src/server.ts` calls loader once per request. It MUST return only complete validated data. Keep loader injectable through `ServerOptions` so tests never depend on workstation files.

### 3. Cascading form state

`FileIncidentForm` consumes `useIncidentOptions()` only. Remove `incidents` as selector authority; retain prop only if another form responsibility still uses it, otherwise remove prop and caller wiring.

State ladder:

1. Options loading: disable CLI, Model, Reasoning effort, Account, Unsafe, and submit; show `Loading options…` placeholders.
2. Options unavailable: disable same controls and submit; show visible retryable error. Preserve title and description.
3. No CLI selected: enable CLI only; dependent placeholders say `Select CLI first`.
4. CLI selected: expose only that CLI's models and accounts; expose Unsafe only when declared.
5. Model selected: expose only that model's efforts.
6. Upstream change invalidates downstream values: clear invalid model, effort, and account atomically.
7. Complete registered selection: enable filing when existing title/description validation also passes.

Do not render `None` as a valid choice for required dispatch fields. Use existing `Select` placeholder and disabled states from `@overdeck/deck-ui`.

### 4. Codex permission-mode contract

Extend `modules/harness/wrappers/codex.sh` at its existing argument parser and launch assembly. `--permission-mode safe` MUST run without `--dangerously-bypass-approvals-and-sandbox`. `--permission-mode unsafe` MUST include that flag. Missing, empty, duplicate, or unknown mode MUST exit `2` before account selection or process launch. Add contract tests that inspect argv for both branches and prove they differ only by registered permission behavior.

## Data flow

```text
deployed adapters.json ─┐
capability manifest ─────┼─> collector option loader ─> GET /incidents/options
provider registries ─────┤                                  │
taxonomy.json ───────────┘                                  v
                                                    FileIncidentForm cascade
                                                             │
                                                             v
                                                   POST /incidents
                                                             │
                                           server combination validation
                                                             │
                                                             v
                                                     Kanboard mutation
                                                             │
                                                   later dispatch request
                                                             │
                                           fresh authority revalidation
                                                             │
                                                    registered wrapper
```

## Error handling

- Missing/invalid source: fail route closed with named `503`; log source path and parse reason server-side, return safe detail to UI.
- Empty capability result: treat as invalid source, not successful empty dropdowns.
- Account registry changes between options fetch and submission: reload authority during submission; reject stale account without mutation.
- Options query refresh removes current choice: clear invalid dependent state and display selection-required validation.
- Network failure: keep user-entered narrative fields; retry options query without full-page reload.

## Testing

### Collector

- Valid manifest joins adapter models and provider accounts.
- Empty incident store does not affect options.
- Fixed-account capability yields exactly one `fixed:true` account.
- Profile-account capability yields registry accounts without incident dependence.
- Missing, malformed, unknown-version, duplicate, mismatched-adapter, and unknown-model inputs fail closed.
- Missing account registry returns `incident-accounts-unavailable`.
- Route never returns partial `200`.
- POST rejects each tampered dimension before Kanboard mutation.
- POST rejects unsupported unsafe mode and stale/unready account.
- Valid request persists server-resolved `wrapperModel`.
- Dispatch reloads authority and refuses a persisted combination removed after filing.
- Codex wrapper safe argv omits bypass flag; unsafe argv contains it; missing/unknown mode launches nothing.

### Web

- Empty incident list still renders populated CLI options from API.
- Selecting CLI populates only its models and accounts.
- Selecting model populates only its efforts.
- Changing CLI clears stale model, effort, and account.
- Loading and API failure states disable filing and remain honest.
- Required selector placeholders replace `None`.
- Options retry preserves title and description.
- Remove seeded resolved-incident workaround from `IncidentsContent.test.tsx`.

### Verification

This change touches `modules/workstation/**` and `modules/harness/**`; install-before-landing applies. After clean worktree tests, run `packaging/deploy-local.sh`, assert deployed `dispatch-capabilities.json` and wrapper bytes match the tested worktree, invoke the real installed options endpoint and wrapper contract probes, then verify live `/incidents`. Land only after live verification.

Run clean:

```text
bun test                         # collector/
pnpm --filter web typecheck
pnpm --filter web build
pnpm --filter web test
```

Use project E2E wrapper for browser verification. Verify live `/incidents`: with no seeded incidents, choose `codex`; Model and Account contain authoritative values; changing CLI removes incompatible selections.

## Architecture Decisions

- Keep collector loader boundary. Deleting it scatters filesystem parsing, schema validation, joining, and trust-boundary checks across route and service.
- Keep capability manifest separate from `adapters.json`. Adapter registry owns general harness discovery; incident manifest owns stricter incident-wrapper contract and mapping. Merging would force incident-only fields onto non-incident adapters.
- Reuse routing-config account reader semantics. A second account parser fails single-source-of-truth and drift tests.
- Collapse selector helpers into form-local pure derivation unless tests prove reuse. Remove orphaned `KNOWN_CLIS`, `REASONING_EFFORTS`, `cliFilingOptions`, `modelFilingOptions`, `reasoningEffortFilingOptions`, and `accountFilingOptions` from `incident-view.ts`. Remove `incidents` prop plumbing from `FileIncidentForm` and its caller when no other responsibility uses it.
- Reject incident-history fallback. It is neither authoritative nor valid on empty stores.
