Skip to content

Derive dispatcher operation groups from the spec objects that already define them #4395

Description

@Astro-Han

Problem

HOST_OPERATION_SPECS is assembled by merging per-domain spec objects, so the domain of every operation is already established by which object declares it. operation-dispatcher.ts then declares that same grouping a second time, by hand, as a literal list of operation names:

export type SessionRetirementOperationKey = Extract<
  OperationKey,
  'session.lifecycle.set' | 'session.remove'
>;
export type SessionCatalogOperationKey = Exclude<
  Extract<OperationKey, `session.${string}`>,
  SessionContinuityOperationKey | SessionRevisionOperationKey | SessionRetirementOperationKey | SessionEffectOperationKey
>;

The file already knows the better form. The host-core groups derive their keys instead of restating them:

export type HostCoreOperationKey =
  | keyof typeof HOST_BOOTSTRAP_OPERATION_SPECS
  | keyof typeof ACCESS_AUTHORITY_OPERATION_SPECS
  | keyof typeof SESSION_COLLABORATION_OPERATION_SPECS
  | keyof typeof PEER_MESH_OPERATION_SPECS;

Three groups use that form. The other thirty-four hand-list operation names. So this is not a new pattern to introduce; it is converging on the one already in the file.

Two costs follow. Adding an operation means remembering to extend the second copy. And because Catalog is defined by subtraction, an operation nobody claims does not fail loudly — it is swept into Catalog. Pick<OperationHandlerMap, K> does catch the omission, but it reports a missing handler on the catalog coordinator, which is not where the mistake was made.

The two groupings have already diverged, which is the substantive part of this report rather than a hypothetical:

  • SessionContinuityOperationKey spans two spec objects: SESSION_CONTINUITY_OPERATION_SPECS (subscription.open, subscription.close) and SESSION_TRANSCRIPT_OPERATION_SPECS (session.transcript.page, session.transcript.overlay.release).
  • SESSION_TURNS_OPERATION_SPECS (session.turns.query, session.turn_landmarks.query) has no dispatcher group at all. Its operations reach the catalog coordinator only because the subtraction sweeps them there.

Two more restatements in the same file

  • createUnavailableAccessAuthorityOperationHandlers hand-writes sixteen handlers across roughly 155 lines whose bodies differ only in one message string, and in doing so re-lists all sixteen operation names. That list is exactly keyof of the two spec objects — verified identical today, so it has not drifted yet. The same file already does this job with a loop, in createUnavailableDomainOperationHandlers. Both spec files' shared error constant includes operation_unavailable, so that loop's existing assertion would pass for all sixteen.
  • The host-core partition is represented twice: at type level as HostCoreOperationKey, and again at runtime as an Object.hasOwn(...) || Object.hasOwn(...) chain inside createUnavailableDomainOperationHandlers. Adding a fifth host-core spec object requires editing both.

Desired outcome

Adding an operation means declaring it in its domain spec object and implementing it in a coordinator. Nothing in the dispatcher restates operation names, and no operation can join a group by omission.

The rule this settles

Protocol domain and coordinator ownership are two different axes and are allowed to differ permanently:

A protocol domain is a contract — versioned and visible to peers. A coordinator is state ownership — internal and invisible to peers. Forcing them one-to-one would mean that splitting a coordinator for state reasons, a purely internal refactor, requires moving operations between spec objects: a wire change, an epoch bump, and peer coordination. That converts a free internal decision into an expensive versioned one.

So the difference is allowed, but it must be declared once, explicitly, on the server side — the dispatcher names the spec objects a coordinator serves. Never the reverse: a spec object must not name a coordinator, because the protocol side is the versioned one and churn belongs on the cheap side. Neither side restates the other's operation names.

Today's state is the worst combination: the axes already differ, and nothing says so — it can only be reconstructed from hand-copied names and a subtraction.

The shape

export type SessionRetirementOperationKey = keyof typeof SESSION_RETIREMENT_OPERATION_SPECS;

export type SessionContinuityOperationKey =
  | keyof typeof SESSION_CONTINUITY_OPERATION_SPECS
  | keyof typeof SESSION_TRANSCRIPT_OPERATION_SPECS;

export type SessionCatalogOperationKey =
  | keyof typeof SESSION_CATALOG_OPERATION_SPECS
  | keyof typeof SESSION_TURNS_OPERATION_SPECS;

Once every group is derived the Exclude subtraction has nothing left to do and should go with it; after that, a spec object no coordinator claims is a compile error rather than a silent reassignment.

The two sibling restatements collapse the same way: replace the hand-written unavailable-handler map with a loop over its two spec objects, parameterised by message, and drive both the type-level and runtime host-core partition from a single const HOST_CORE_SPEC_OBJECTS = [...] as const.

Suggested approach for whoever picks this up

Do not compare thirty-four groups by eye. Make the compiler find the divergences first, then convert.

  1. Add a temporary equality assertion and instantiate it once per group in operation-dispatcher.ts:

    type AssertEqual<A, B> = [A] extends [B] ? ([B] extends [A] ? true : never) : never;
    type _CheckRetirement = AssertEqual<
      SessionRetirementOperationKey,
      keyof typeof SESSION_RETIREMENT_OPERATION_SPECS
    >;
  2. Typecheck. Every failure is either a real divergence like the two named above, or a coordinator whose derived side needs more than one spec object. Extend the right-hand side to the union of spec objects that coordinator actually serves, and re-check.

  3. If a coordinator serves only part of a spec object, stop and report it here. That is a routing decision worth discussing, not something to paper over with another Exclude.

  4. Replace each union with its derived form, delete the assertions, and delete the subtraction.

  5. Add one operation to a spec object locally and confirm it reaches the intended coordinator with no dispatcher edit, and that removing its handler is a compile error on that coordinator.

The change is type-only. Pick<OperationHandlerMap, K> keeps enforcing handler completeness throughout, so a mistake surfaces as a compile error rather than a routing bug at runtime.

Alternatives or workarounds

  • Rename operations to session.retirement.* and friends, so the existing prefix trick keeps working. Rejected: this pays a cross-cutting protocol rename, a compatibility epoch, and peer coordination to recover a fact that is already available for free.
  • Add a domain field to each operation spec and group by it. Rejected: it encodes what the file layout already encodes, so it adds a declaration instead of removing one, and the field can disagree with the object the spec lives in.
  • Delete the grouping types entirely and let each coordinator take Partial<OperationHandlerMap>, relying on the runtime totality check composeOperationHandlers already performs. Rejected despite removing more lines: it turns "which coordinator owns which domains" from a statement into an emergent property of whichever handlers each coordinator happens to define. Fewer lines, more entropy — you would have to read every coordinator to recover the ownership map.
  • Reorganise spec objects one-to-one with coordinators. Rejected by the rule above: it lets server-side shape dictate protocol file layout, so internal refactors leak into a versioned boundary.
  • Leave it and keep paying a line per operation. The compiler does catch omissions, but in the wrong place, and the two drifted rows show the grouping has already moved once without anyone deciding to.

Additional context

Neither the divergences nor the duplication are defects in #3781, which followed the existing structure correctly. They predate it and surface in any change that adds a Host operation.

A separate, smaller item found alongside this: { disposition, archivedSubtaskCount } is written out independently in runtime-host-client.ts, bridge-contract.d.ts, ports.ts, and locally in session-row-actions.ts. The local type duplicates SessionNavigationRemoveOutcome from ports.ts in the same feature directory, comment included, and should just import it. The main-process client's type is a genuine translation of the protocol result and should stay distinct.

REMOTE_OWNER_OPERATION_GRANTS is not part of this. It is a fail-closed security allow-list whose comment states the intent: adding an operation must not grant it to remote owners until the policy is deliberately updated. Hand-maintaining it is the point — do not fold it into any derivation here.

Analysis produced with Claude Code while reviewing #3781, and verified against main at c76fbda.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions