# Hook controls design

audience: AI coding agents first

## Goal

Ship reusable hook enable/disable control on `/hooks`. First control governs `bg-gate.sh`: enabled blocks agent-initiated background jobs; disabled passes them through. UI changes live policy without editing Claude settings or launching Claude processes.

## Hard requirements

1. MUST keep hook registration in `modules/workstation/claude/settings.json`. Toggle runtime behavior, not registration.
2. MUST store mutable policy at `${OVERDECK_CONFIG_DIR:-$HOME/.config/overdeck}/hook-controls.json`.
3. MUST NOT store mutable policy in git checkout or deploy clone. Deploys MUST preserve policy.
4. MUST default `background-jobs-blocker` to enabled when file, key, or value is missing or invalid.
5. MUST create config parent with mode `0700`, persist target with mode `0600`, replace atomically, and serialize updates across processes.
6. MUST use existing `Checkbox` from `@overdeck/deck-ui`; do not add generic UI primitives.
7. MUST expose reusable hook-domain control component for later hook controls.
8. MUST test hook behavior by direct stdin invocation only. NEVER run `claude`, `claudex`, or `cld`.
9. MUST install and verify live before landing; then push and run `packaging/deploy-local.sh` per repository policy.

## Configuration contract

File: `${OVERDECK_CONFIG_DIR:-$HOME/.config/overdeck}/hook-controls.json`

```json
{
  "version": "hook-controls/v1",
  "hooks": {
    "background-jobs-blocker": true
  }
}
```

Rules:

- `HOOK_CONTROL_REGISTRY` in collector code MUST be the source of registered IDs and defaults. Derive TypeScript IDs, validation, and effective values from it.
- `version` MUST equal `hook-controls/v1`.
- Persisted `hooks` MUST be a sparse `Record<string, boolean>`; registered keys may be absent and future unknown boolean keys MUST survive updates unchanged.
- Initial registry contains exactly `background-jobs-blocker`, default `true`.
- Missing file or missing registered key returns registry default. Malformed file, wrong version, or invalid value is a configuration error at the API boundary.
- Runtime hook reader MUST fail closed for this safety control: missing, unreadable, malformed, wrong-version, or non-boolean data means enabled.
- Writes MUST reject unknown route IDs, non-boolean request values, extra request fields, unsupported media type, oversized bodies, and cross-origin browser mutations.
- Valid writes MUST preserve unknown persisted boolean keys for forward compatibility.
- API errors MUST leave prior file intact and return stable machine-readable error plus human detail.
- Tests MUST assert shell literal `background-jobs-blocker` and default `true` conform to `HOOK_CONTROL_REGISTRY`; shell cannot import TypeScript registry directly.

DO NOT:

```text
// reject: mutate modules/workstation/claude/settings.json from browser
// reject: write hook-controls.json under ~/.local/share/overdeck/deploy
// reject: disable blocker because config parsing failed
// reject: rewrite whole config from stale browser state
```

## Architecture

### 1. Shared hook runtime primitive

Path: `modules/workstation/claude/hooks/lib/hook-control.sh`

Seam:

```text
hook_control_is_enabled <hook-id> <default-enabled> -> exit 0 when enabled; exit 1 when disabled
```

Behavior:

- Resolve file through `OVERDECK_CONFIG_DIR`; default to `$HOME/.config/overdeck`.
- Read one boolean by ID.
- Use caller-supplied default for missing, unreadable, malformed, wrong-version, or non-boolean data.
- Emit nothing on normal enabled/disabled decisions.
- Remain sourceable under `set -euo pipefail`.

`bg-gate.sh` MUST source helper before stdin capture, then check immediately after `INPUT=$(cat)` and before any `jq`/policy parsing:

```bash
if ! hook_control_is_enabled background-jobs-blocker true; then exit 0; fi
```

Disabled state exits `0` with empty stdout/stderr even when input is malformed. Existing subagent and QuietContext exemptions remain unchanged when enabled.

### 2. Typed config store and collector API

New collector module owns registry, schema validation, effective reads, cross-process serialization, corruption repair, atomic replacement, and permissions. Use `collector/src/paths.ts::configDir()` as path source.

Seams:

```ts
const HOOK_CONTROL_REGISTRY = {
  'background-jobs-blocker': { defaultEnabled: true },
} as const

type HookControlId = keyof typeof HOOK_CONTROL_REGISTRY
type PersistedHookControls = {
  version: 'hook-controls/v1'
  hooks: Record<string, boolean>
}
type EffectiveHookControls = {
  version: 'hook-controls/v1'
  hooks: Record<HookControlId, boolean>
}
type HookControlsResponse = {
  controls: EffectiveHookControls
  issue: null | { code: 'invalid-hook-controls' | 'wrong-hook-controls-version'; detail: string }
  persistence?: 'confirmed' | 'indeterminate'
  persistenceDetail?: string
}

readHookControls(configDirectory?: string): Promise<HookControlsResponse>
setHookControl(id: HookControlId, enabled: boolean, configDirectory?: string): Promise<HookControlsResponse>
repairHookControls(configDirectory?: string): Promise<HookControlsResponse>
```

Routes:

```text
GET  /config/hooks
POST /config/hooks/:id
body: { "enabled": boolean }
POST /config/hooks/repair
body: { "confirm": "replace-invalid-config" }
response: HookControlsResponse
```

- GET returns effective defaults with `issue: null` when file is absent. Corrupt/wrong-version files return safe effective defaults plus non-null `issue`; UI MUST NOT present these as persisted healthy state.
- ID POST validates trust boundary, rejects while `issue` is non-null, merges one registered ID into latest disk state, and preserves unknown boolean keys.
- Repair POST is explicit. It MUST accept only the exact confirmation literal, back up invalid bytes to a sibling mode-`0600` corruption file, then create a valid defaults document. It MUST reject repair when current file is valid.
- Mutations MUST hold one cross-process kernel `flock` lock from latest read through backup/write/rename/fsync. Lock file `.hook-controls.lock` carries no metadata; create with `O_CREAT|O_EXCL|O_NOFOLLOW` mode `0600`, or open existing `O_RDONLY|O_NOFOLLOW`; `fstat` MUST require regular file, current-uid owner, `nlink=1`, mode `0600`; NEVER truncate/write/chmod the lock file after open; every path releases `flock` and closes the fd. Bounded lock wait failure returns service unavailable.
- **Commit-point persistence contract:** every failure before `rename(temp, target)` MUST preserve prior target bytes and return non-2xx. Successful `rename` is the commit point and MUST NOT be rolled back. After rename, fsync the config parent directory. Parent fsync success ⇒ `persistence: confirmed` (field omitted). Parent fsync failure ⇒ reread target and return HTTP 2xx `HookControlsResponse` with `persistence: indeterminate`, `persistenceDetail`, and observed effective state; UI MUST adopt that state, show an alert, and offer explicit `Reconfirm hook controls` that rewrites the same observed value. Do not invalidate away an indeterminate response.
- **GET durability:** every GET MUST hold the same cross-process kernel `flock` from read through parent-directory fsync (with bounded retries) before reporting `persistence: confirmed`; fsync failure ⇒ observed state with `persistence: indeterminate`. A confirmed GET therefore reports the exact generation it durably observed.
- Writes MUST create parent mode `0700`, create temporary file exclusively at mode `0600`, fsync file, rename, fsync parent, and clean temporary files after failure.
- Collector server option MUST permit temporary config-directory injection in tests.
- Same-origin web proxy MUST allow only GET `/config/hooks`, POST `/config/hooks/<registered-id>`, and explicit repair POST; retain existing body bounds and mutation-origin gate.

### 3. Web data seam

Add shared browser types, client methods, query key, query hook, and mutation hook following existing collector query patterns.

Seams:

```ts
fetchHookControls(): Promise<HookControlsResponse>
setHookControl(id: HookControlId, enabled: boolean): Promise<HookControlsResponse>
repairHookControls(): Promise<HookControlsResponse>
useHookControls(): query result
useSetHookControl(): mutation result
useRepairHookControls(): mutation result
```

Use pessimistic mutation state: retain server-confirmed value while pending, disable control until initial GET succeeds, then update cache only after success; do not invalidate away `persistence: indeterminate`. Failed mutation MUST retain confirmed state and expose readable inline error. When `persistence: indeterminate`, adopt observed checkbox state, show `persistenceDetail` in an alert, disable direct toggling, and render explicit `Reconfirm hook controls` action that rewrites the same observed value.

### 4. Reusable hook control UI

Path: `apps/web/src/components/hooks/HookControlToggle.tsx`

Seam:

```text
HookControlToggle({ id, label, hint, enabled, pending, error, onEnabledChange }): JSX
```

- Compose existing `Checkbox`; do not create a new deck-ui primitive.
- Disable input before initial GET succeeds and while mutation is pending.
- Expose enabled meaning directly: checked = blocker enabled = background jobs forced to foreground.
- Render persistent hint: disabling permits agent-initiated background commands.
- Render mutation error in an announced live region and connect hint/error via `aria-describedby`; never claim success before server response.
- When response `issue` is non-null, disable toggle, show issue, and offer explicit existing-`Button` repair action labeled `Repair hook controls`. Repair confirmation MUST be sent only from that action.
- When `persistence: indeterminate`, show observed enabled state, disable direct toggling, announce `persistenceDetail`, and offer explicit `Button` labeled `Reconfirm hook controls` that rewrites the same observed value.
- Place control in `HooksPanel` top row, before inventory counts/generated timestamp, while preserving responsive wrapping and keyboard access.

## Data flow

```text
/hooks Checkbox
  -> same-origin POST /api/collector/config/hooks/background-jobs-blocker
  -> authenticated collector POST /config/hooks/background-jobs-blocker
  -> atomic ~/.config/overdeck/hook-controls.json update
  -> bg-gate.sh sources hook-control.sh on next invocation
  -> enabled: existing deny policy; disabled: clean passthrough
```

No daemon reload or Claude restart is required; next hook invocation observes renamed file.

## Error handling

- Missing config: API and hook use registry default `true`.
- Corrupt/wrong-version config: hook remains enabled; GET returns safe effective state plus `issue`; ID POST returns conflict; explicit repair backs up bytes before reset.
- Failed write before commit: preserve old file; API non-2xx; UI retains prior confirmed checkbox state and shows error.
- Post-commit parent fsync failure: rename is not rolled back; API returns HTTP 2xx with observed state and `persistence: indeterminate`; UI adopts observed state and offers `Reconfirm hook controls`.
- Concurrent writes: cross-process lock covers latest read through durable rename; no lost updates.
- Proxy/collector unavailable: existing query boundary and mutation error surfaces apply.

Stable API errors:

| Status | Code | Condition |
|---|---|---|
| 400 | `invalid-hook-control-request` | malformed JSON, extra/missing fields, wrong value type |
| 401 | `unauthorized` | missing/invalid collector bearer token |
| 403 | `forbidden-origin` | missing, `null`, or foreign browser mutation Origin |
| 404 | `unknown-hook-control` | unregistered route ID |
| 405 | `method-not-allowed` | unsupported method on known route |
| 409 | `hook-controls-invalid` | mutation against corrupt/wrong-version file or repair against valid file |
| 413 | `payload-too-large` | declared or streamed body exceeds bound |
| 415 | `unsupported-media-type` | mutation is not JSON |
| 503 | `hook-controls-locked` | bounded lock wait expires |
| 500 | `hook-controls-write-failed` | persistence operation fails |

## Verification

### Unit and integration

1. Shell tests with temporary `OVERDECK_CONFIG_DIR`:
   - missing file => direct `bg-gate.sh` crafted background input returns deny JSON;
   - explicit `true` => deny;
   - explicit `false` plus malformed hook input => exit `0`, empty stdout/stderr before `jq` parsing;
   - malformed/wrong-version/non-boolean => deny;
   - shell ID/default conformance matches collector registry;
   - existing foreground/exemption cases remain green.
2. Collector tests:
   - default read; sparse persisted/effective materialization; valid round trip; unknown persisted key preservation; unknown route ID rejection; corrupt and wrong-version issue reporting; explicit repair backup; parent `0700`; target/backup `0600`; exclusive temporary creation; kernel flock lock symlink/hardlink rejection; stale/live lock behavior; queued and cross-process mutations; pre-rename failure preserves target bytes/non-2xx; post-rename parent fsync failure returns observed indeterminate HTTP success; GET directory fsync retries/confirms; no marker or rollback artifact leaks.
3. Proxy/API security tests:
   - exact GET/POST allowlist; missing/invalid collector authentication; missing/`null`/foreign Origin; exact JSON shape; JSON content type; unsupported methods; unknown path/ID; declared and streamed body limits; stable status/code mapping.
4. React tests:
   - top-row control reflects fetched state; initial/pending states disable; pessimistic success updates; failure retains confirmed state and announces error; issue disables toggle and explicit repair action clears it; indeterminate adopts observed state and explicit reconfirm action rewrites it.
5. Existing gates:
   - workstation hook tests;
   - `bun test` in `collector/`;
   - `pnpm --filter @overdeck/deck-ui test` and typecheck only if deck-ui changes occur;
   - `pnpm --filter web build` and typecheck;
   - slopgate with no new suppression.

### Live, quota-free

1. Build/test worktree, then deploy tested candidate commit into clean deploy clone as detached source and build/install/restart the same service set as `packaging/deploy-local.sh`. MUST NOT run `packaging/deploy-local.sh` before landing because it intentionally resets to landed `origin/main`.
2. Assert collector and installed hook resolve the same production path: `$HOME/.config/overdeck/hook-controls.json`. Clear incidental test-shell `OVERDECK_CONFIG_DIR` overrides.
3. Capture pre-test effective state and file existence/bytes. Install cleanup trap before first disabling request; trap MUST restore original enabled value or original missing-file state on every exit and verify restoration.
4. GET live config through `http://127.0.0.1:31337/api/collector/config/hooks`; assert web/collector service health and `/home/user/.claude/hooks/bg-gate.sh` resolves into deployed source.
5. POST disabled with correct same-origin header. Invoke installed `bg-gate.sh` directly with crafted malformed/background `Bash` JSON; assert clean passthrough.
6. POST enabled. Invoke same installed script; assert deny JSON. Run cleanup and assert original state restored.
7. Run browser E2E through `e2e-remote` against a production build plus fixture collector on the buildbox; do NOT point remote browser at workstation `127.0.0.1`. Separately assert deployed workstation `/hooks` and its production assets return success.
8. Land, run final `packaging/deploy-local.sh`, then re-check service health, config persistence/restored state, installed hook target, GET response, and direct hook behavior matching restored state.
9. NEVER execute `claude`, `claudex`, or `cld` during verification.

## Scope

Included: reusable policy schema/store/API/runtime reader/UI control; first `bg-gate.sh` adoption; tests; live install; push; deploy.

Excluded: controlling other hooks, changing Claude hook registrations, controlling Claude core auto-background behavior, audit-history UI, generalized policy expressions.

## Architecture Decisions

- Keep four boundaries. Runtime shell reader, collector trust boundary, browser data seam, and hook-domain UI each hide distinct implementation and test concerns; deleting one scatters complexity across callers.
- Depth: runtime reader medium; config store/API deep; browser data seam medium; hook-domain UI medium.
- Keep runtime reader despite one initial caller. User explicitly requires reusable hook enablement and plans more hook adopters; helper hides shell parsing/fail-closed semantics from each hook.
- Keep hook-domain UI despite one initial control. It hides pending/error/a11y behavior and gives later hook rows one stable interface.
- Collapse generic visual primitive proposal. Existing `Checkbox` already owns generic interaction/accessibility; `HookControlToggle` remains app-level domain composition.
- Reject per-hook config files. One typed map prevents endpoint/file proliferation and supports later controls.
- Reject mutable config in deploy clone. Deploy replacement would violate persistence and dirty operational source.
