|
| 1 | +# ADR 0008: Command Descriptor Registry |
| 2 | + |
| 3 | +## Status |
| 4 | + |
| 5 | +Proposed |
| 6 | + |
| 7 | +## Context |
| 8 | + |
| 9 | +A command's identity is restated, by hand, across roughly ten tables that must stay aligned by |
| 10 | +convention: `PUBLIC_COMMANDS` (`src/command-catalog.ts`), the per-command metadata and family facets |
| 11 | +(`src/commands/**`), the capability matrix (`src/core/capabilities.ts`), the daemon command registry |
| 12 | +(`src/daemon/daemon-command-registry.ts`, ADR 0003), the structured-batch allowlist |
| 13 | +(`src/batch-policy.ts`), the MCP exposure sets, the Node client interface and impl (`src/client-types.ts`, |
| 14 | +`src/client.ts`), and the generic-dispatch `switch` (`src/core/dispatch.ts`, whose `default: throw` makes a |
| 15 | +missing or renamed command a runtime error, not a compile error). Adding one command touches ~24 files, the |
| 16 | +argument shape is (de)serialized ~4 times, and the gesture set is retyped in three places. |
| 17 | + |
| 18 | +The codebase already proves the cure works for part of this: the `CommandFamilyFacet` |
| 19 | +(`src/commands/family/`) derives the MCP tools, the CLI schema, and the batch writer from a single array. |
| 20 | +It simply stops at the command-surface boundary; everything past it is hand-maintained. |
| 21 | + |
| 22 | +ADR 0003 deliberately separated daemon route/policy into its own internally-owned registry with a small |
| 23 | +predicate interface, and its 2026-06 update set four invariants that any single-declaration/derivation |
| 24 | +model must preserve. This ADR is that model. |
| 25 | + |
| 26 | +## Decision |
| 27 | + |
| 28 | +Introduce one `CommandDescriptor` per command that **composes facets owned by their domains** and from which |
| 29 | +every consumer table is **derived** by pure, parity-tested projection: |
| 30 | + |
| 31 | +- The descriptor composes a `surface` facet (owned by `src/commands/**`: identity, CLI schema/reader, MCP), |
| 32 | + a `capability` facet (owned by `src/core/capabilities`), and a `daemon` facet (route + request-policy |
| 33 | + traits, **owned under `src/daemon/`** per ADR 0003), plus a typed result. |
| 34 | +- The public catalog, capability matrix, daemon command registry, batch allowlist, MCP tool list, CLI |
| 35 | + schema, and the Node client surface become pure projections of the descriptor set. The |
| 36 | + `src/core/dispatch.ts` `switch` is replaced by a total map keyed on the command-name union, so a missing |
| 37 | + handler is a compile error. |
| 38 | +- The cross-process `invoke` (client) and in-daemon `execute` seams stay distinct; the process boundary is |
| 39 | + never collapsed. |
| 40 | + |
| 41 | +This **composes with**, and is bound by, ADR 0003's four invariants: daemon-owned declaration (never inlined |
| 42 | +into the public surface), the predicate interface unchanged, no leakage of daemon-only traits into public |
| 43 | +projections, and one declaration per concern enforced by the type system. |
| 44 | + |
| 45 | +## Alternatives Considered |
| 46 | + |
| 47 | +- Keep the hand-synced tables: no migration risk, but it is the status quo this ADR exists to remove — |
| 48 | + ~24-file cost per command and drift kept in check only by convention and tests. |
| 49 | +- A single flat public descriptor with daemon fields inlined: re-contaminates the public command surface |
| 50 | + with daemon-only policy, which is exactly what ADR 0003 (and its update) forbid. |
| 51 | +- Build-time code generation: a real option, but runtime derivation with `as const satisfies` keeps the |
| 52 | + source of truth in type-checked TypeScript with no separate build step or generated artifacts to review. |
| 53 | + |
| 54 | +## Consequences |
| 55 | + |
| 56 | +Adding a plain command touches ~1–2 files; per-platform behavior remains N implementations behind the |
| 57 | +descriptor's `execute`. The descriptor is the prerequisite for typed per-command results (ADR 0010, which |
| 58 | +deletes the `src/client-types.ts` mirror) and supplies the capability facet the platform-plugin work |
| 59 | +(ADR 0009) hooks into. |
| 60 | + |
| 61 | +Migration is **strangler-fig and sequential** — never a big-bang: |
| 62 | + |
| 63 | +1. Introduce the `commandRegistry` as the root and **invert the import graph** so `command-catalog`, |
| 64 | + `capabilities`, `daemon-command-registry`, and `batch-policy` become leaves that derive from it. |
| 65 | +2. Promote each family's facet to a full `CommandDescriptor`, family by family. |
| 66 | +3. Replace the dispatch `switch` with the registry-driven total map, arm by arm. |
| 67 | + |
| 68 | +Each derived table must be asserted **byte-for-byte equivalent** to the hand-authored table by a parity test |
| 69 | +**before** the hand table is deleted. The principal risk is the import-cycle inversion: `command-catalog.ts` |
| 70 | +has ~95 importers and the family facet currently imports `AgentDeviceClient`, so the descriptor module must |
| 71 | +own the `Input`/`Result` types and the client must be derived as a view type, enforced by a lint boundary. |
| 72 | +Until this lands and the registry tests pin it, the hand-authored tables remain the source of truth. |
| 73 | + |
| 74 | +`plans/perfect-shape.md` (§5.2) holds the prototype; this ADR owns the decision and its constraints. |
0 commit comments