Files
boocode/docs/coding-standards/cross-app-contract-parity.md
indifferentketchup 649ce71eff feat: single-source cross-app wire contracts in @boocode/contracts (v2.7.13)
Move all hand-synced cross-app wire contracts into one built workspace
package, @boocode/contracts, consumed by server/web/coder/coder-web via
workspace:* + a per-subpath exports map. The ws-frames and provider-config
Zod schemas are schema-first (z.infer); MessageMetadata, ErrorReason,
AgentSessionConfig, the provider snapshot types, and WorktreeRiskReport are
each single-sourced. Deletes the byte-identical copies and their parity
tests, fixes a live AgentSessionConfig drift (coder dead copy removed,
unified to the web required/nullable shape), removes the dead pending_change
WS arms in the fallback SPA, and inverts the build order (contracts builds
first) across root build, Dockerfile, and the coder deploy docs. Reverses
the shared-package decision declined in v2.5.12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:24:08 +00:00

17 KiB

paths
paths
packages/contracts/src/ws-frames.ts
packages/contracts/src/provider-snapshot.ts
packages/contracts/src/message-metadata.ts
apps/server/src/types/api.ts
apps/web/src/api/types.ts
apps/coder/src/services/provider-types.ts
apps/web/src/components/MessageBubble.tsx
apps/server/src/services/inference/turn.ts

Cross-App Contract Parity

Reach for this when adding a new WS frame type, a new sentinel kind, or touching the server's InferenceFrame loose union or the web's strict WsFrame union — contracts whose primary definition now lives in @boocode/contracts but that still have split secondary representations in the apps. The fix-it path is When to Apply + its Verification step.

  • Status: proposed
  • Date Created: 2026-06-02 00:00
  • Last Updated: 2026-06-02 00:00
  • Authors:
  • Reviewers:
  • Applies To:
    • The remaining split contracts that cross app boundaries: the server's InferenceFrame loose publish union (services/inference/turn.ts), the web's strict WsFrame discriminated union (apps/web/src/api/types.ts), and the MessageBubble sentinel render arm (apps/web/src/components/MessageBubble.tsx). The primary contracts (WS-frame Zod schema, provider-snapshot types, MessageMetadata, WorktreeRiskReport, provider-config schemas) are now single-sourced in @boocode/contracts — those are governed by editing the package, not by this sync-both-copies rule.

Introduction

The primary wire contracts (WS-frame Zod schema, provider-snapshot types, provider-config schemas, MessageMetadata, WorktreeRiskReport, AgentSessionConfig) are now single-sourced in @boocode/contracts — edit packages/contracts/src/<subpath>.ts and rebuild the package. There is no second copy to sync for those contracts.

What this standard still governs: adding a new WS frame type or a new sentinel kind touches contracts whose primary definition is in the package but that also have split secondary representations in the apps. The server's InferenceFrame loose publish union (services/inference/turn.ts) and the web's strict WsFrame discriminated union (apps/web/src/api/types.ts) both still exist separately from the canonical WsFrameSchema in the package, and must be updated together with it. The sentinel MessageBubble render arm (apps/web/src/components/MessageBubble.tsx) has no compile-time guard and still needs updating when a new MessageMetadata kind is added to the package.

Purpose

  • Primary: prevent silent runtime contract breakage. Nothing at compile time links the copies — each app type-checks against its own copy, so tsc stays green when they drift. The failure surfaces only at runtime, and silently: a WS frame whose type exists on one side but not the other is dropped at JSON-parse with no error; a sentinel kind added without a render arm shows nothing. Editing every copy in lockstep is the only thing that keeps the contract whole.
  • Secondary: the @boocode/contracts package's ws-frames.test.ts tests schema correctness (accept/reject behavior) and the KNOWN_FRAME_TYPES drift probe. provider-types-parity.test.ts was deleted when the provider-snapshot types moved to the package. The sentinel render arm still has no automated test.
  • Side effect: for the remaining split representations (server InferenceFrame, web WsFrame), updating them together with the package source in a single commit makes the change reviewable as a matched diff across files.

Scope

The specific duplicated contracts listed in paths: above, inside the apps/server, apps/web, and apps/coder TypeScript packages. It does not govern types that live in a single app.

When to Apply

Walk this before editing a type, schema, enum, or metadata union:

  1. Does this shape exist as a copy in another app? — Check: grep -rn "<TypeOrFieldName>" apps/*/src. If it appears under two or more of apps/server, apps/web, apps/coder → continue. If it lives in exactly one app → see "When NOT to Apply".
  2. Are you changing its wire shape? — adding, removing, renaming, or re-typing a field; adding/removing a frame type; adding an enum value or a sentinel kind. If yes → apply this standard: update the @boocode/contracts package source, rebuild, and also update every secondary app-side representation (the server InferenceFrame loose union, the web WsFrame strict union, and the MessageBubble render arm for sentinel kinds), in the same commit. If no (a comment or formatting change) → see "When NOT to Apply".

Exception — the sentinel render arm: MessageMetadata is single-sourced in @boocode/contracts/message-metadata (one definition, no second copy). However, a new kind is inert until it gets a render branch in apps/web/src/components/MessageBubble.tsx. There is no test to catch a missing render arm — when adding a kind, the render branch in MessageBubble.tsx is the one consumer you must update manually.

Verification step: run the guards that exist now, before you commit:

# Build the contracts package first, then run its tests.
pnpm -C packages/contracts build
pnpm -C packages/contracts test ws-frames.test     # WS-frame schema correctness + KNOWN_FRAME_TYPES drift
# provider-types-parity.test.ts was deleted — provider snapshot types are in @boocode/contracts

# Sentinel render arm has no test — grep for the new kind in the package definition and MessageBubble:
grep -rn "<new-kind>" packages/contracts/src/message-metadata.ts apps/web/src/components/MessageBubble.tsx

For a rendering sentinel kind (cap_hit / doom_loop / mistake_recovery) the new kind must appear in packages/contracts/src/message-metadata.ts and have a render branch in MessageBubble.tsx. The non-rendering error arm has no MessageBubble branch — for it, only the package definition needs updating.

When NOT to Apply

  • The type lives in a single app. Internal server types, web-only view models, coder-only helpers — there is no second copy, so there is nothing to sync. Edit the one definition directly; do not manufacture a duplicate in another app "for symmetry." A new cross-app contract should prefer the eventual shared package or, at minimum, ship with its own parity test — not a third hand-synced copy.
  • A comment- or whitespace-only edit to a @boocode/contracts source file. The package's ws-frames.test.ts tests behavior and structure, not formatting, so a comment-only change does not require secondary app-side updates.
  • The type lives in @boocode/contracts and has no split secondary representations. For the migrated contracts (WS-frame Zod schema, provider-snapshot, provider-config, MessageMetadata, WorktreeRiskReport) the package IS the single source of truth. Edit the package source; there is nothing to sync in the apps. The split representations that still require multi-file changes are the server InferenceFrame loose union, the web WsFrame strict union, and the MessageBubble render arm — all documented in When to Apply.

Background

The history below explains why the duplication existed, not the current state. The primary contracts (WS-frame Zod schema, provider-snapshot types, provider-config schemas, MessageMetadata, WorktreeRiskReport) were formerly hand-synced copies guarded by runtime parity tests — because apps/web/tsconfig.app.json is a composite project and rejects out-of-include files with TS6307, blocking cross-project type import. The WS-frame copies were kept byte-identical so a single readFileSync equality test could guard them; provider-snapshot copies were kept text-identical per named block. These contracts have since moved to @boocode/contracts (the packages/contracts workspace package), the hand-sync discipline and the byte-parity test are retired, and drift is prevented by there being exactly one definition. The split representations that remain (server InferenceFrame loose union, web WsFrame strict union, MessageBubble render arm) still require lockstep edits when frame types or sentinel kinds are added — that is what this standard now governs.

Coding Standard

Edit all copies of a cross-app contract together (cross-cutting)

When you change one copy of a duplicated contract, change the others in the same commit. Each contract family has its own home files and its own (or no) guard.

WS frame schema — single-sourced at packages/contracts/src/ws-frames.ts (imported as @boocode/contracts/ws-frames):

// Single source of truth: WsFrameSchema (Zod), WsFrame (z.infer), KNOWN_FRAME_TYPES.
// No second copy to sync. When adding a frame type:
//   1. Add it here (the canonical schema definition) and rebuild the package.
//   2. Also add to the server's InferenceFrame loose union (services/inference/turn.ts).
//   3. Also add to the web's strict WsFrame discriminated union (apps/web/src/api/types.ts).
// Adding a frame also means adding its `type` to KNOWN_FRAME_TYPES — the package
// test probes every entry for a discriminated branch.

Provider snapshot types — single-sourced at packages/contracts/src/provider-snapshot.ts (imported as @boocode/contracts/provider-snapshot):

// Single source of truth for ProviderSnapshotEntry, ProviderModel, ProviderMode,
// ThinkingOption, AgentCommand, ProviderSnapshotStatus. No second copy to sync.
// apps/coder/src/services/provider-types.ts re-exports from this package.
// provider-types-parity.test.ts was deleted — drift is prevented by the single definition.
export interface ProviderSnapshotEntry { /* ...fields... */ }

Sentinel metadata — packages/contracts/src/message-metadata.ts (single source) plus the render arm in apps/web/src/components/MessageBubble.tsx (no automated test):

// MessageMetadata is single-sourced in @boocode/contracts/message-metadata.
// A new *rendering* sentinel kind is a TWO-step change with NO test to catch a miss:
//   1. packages/contracts/src/message-metadata.ts — add the arm to MessageMetadata, rebuild
//   2. MessageBubble.tsx             — add the render branch, else it shows nothing
// The real union has FOUR arms; show it whole so nobody reads two as the full set:
export type MessageMetadata =
  | { kind: 'cap_hit';          /* used, limit, agent_name, can_continue */ }
  | { kind: 'doom_loop';        /* tool_name, args, threshold */ }
  | { kind: 'mistake_recovery'; /* failure_kinds, count, escalated */ }  // PINNED CONTRACT (#12), mirrored byte-for-byte
  | { kind: 'error';            /* error_reason, error_text */ };        // NOT a rendered sentinel → 2-file change
//
// CROSS-APP CAVEAT for the MessageBubble render branch: the coder feeds rows in via
// `CoderMessageWire as unknown as Message`, so `metadata` can be undefined there.
// Null-guard the loose way — `message.metadata?.kind === 'x'` or `metadata != null` —
// NEVER `metadata !== null` (undefined !== null is true → `.kind` throws → blank
// screen, and tsc can't see it). See apps/web/CLAUDE.md.

What to avoid:

// ANTI-PATTERN: editing only the package schema without updating the app-side representations.
// Add a new frame type to packages/contracts/src/ws-frames.ts but not to the
// web's strict WsFrame union (apps/web/src/api/types.ts): tsc stays green because
// they're separate projects, but the frontend silently discards the frame at
// JSON-parse. A half-edited contract is invisible to the type-checker; never land one.

Project references:

  • packages/contracts/src/ws-frames.tsWsFrameSchema, WsFrame, KNOWN_FRAME_TYPES (via @boocode/contracts/ws-frames). The two former app-level ws-frames.ts copies are deleted.
  • packages/contracts/src/provider-snapshot.ts — provider snapshot types (via @boocode/contracts/provider-snapshot). The former web mirror block in apps/web/src/api/types.ts and the former coder copy in provider-types.ts are deleted; provider-types.ts now re-exports from the package.
  • packages/contracts/src/message-metadata.tsMessageMetadata, ErrorReason (via @boocode/contracts/message-metadata). The former copies in apps/server/src/types/api.ts and apps/web/src/api/types.ts are deleted.
  • apps/web/src/api/types.ts — the web-local strict WsFrame discriminated union (still maintained separately from the canonical schema in the package).
  • apps/web/src/components/MessageBubble.tsx — the sentinel render arms (metadata?.kind branches).

A wire-shape change passes through the gate, then a consumer

A frame is published by the server's permissive InferenceFrame union (apps/server/src/services/inference/turn.ts) but only reaches the UI if the strict schema/union accepts it — permissive publish, strict receive. Keep the type/schema copies (this standard's scope) in lockstep so the frame survives validation; then make sure something consumes it.

Where consumer-wiring fits. This standard governs the duplicated type/schema copies and the one consumer with no compile-time guard — the sentinel MessageBubble render arm. A new WS frame additionally needs a runtime handler to do anything: applyFrame in apps/web/src/hooks/useSessionStream.ts (per-session frames) and useUserEvents (user-channel frames), plus the sidebar reducer. That wiring — and the event-dedup discipline around it — is governed by apps/web/CLAUDE.md, not by this parity standard. A frame that passes WsFrameSchema validation but has no reducer case is then silently ignored.

Correct usage:

// Adding a WS frame type, all in one commit:
//   - packages/contracts/src/ws-frames.ts         — WsFrameSchema + WsFrame + KNOWN_FRAME_TYPES (rebuild package)
//   - apps/server/src/services/inference/turn.ts  — loose InferenceFrame publish union (+ optional fields)
//   - apps/web/src/api/types.ts                   — strict WsFrame discriminated union (the web gate)
// The web strict WsFrame is the wire-format gate: a frame whose type isn't in
// it is dropped at JSON-parse. The loose publish union and the strict gate are
// BOTH required — permissive publish, strict receive. The canonical schema in the
// package is validated by the broker fail-closed (Zod) on every publish.

What to avoid:

// ANTI-PATTERN: widening the server publish union but not the strict schema.
// turn.ts now emits { type: 'my_new_frame', ... }; the broker Zod-validates
// against WsFrameSchema, which doesn't know the type, and fail-closed drops it.
// The feature "does nothing" with no error in either app's logs.

Project references:

  • packages/contracts/src/ws-frames.tsWsFrameSchema (the broker's fail-closed validation gate) + WsFrame + KNOWN_FRAME_TYPES.
  • apps/server/src/services/inference/turn.ts — the loose InferenceFrame publish union.
  • apps/web/src/api/types.ts — the web-local strict WsFrame discriminated union.
  • apps/web/src/components/MessageBubble.tsx — the consumer for sentinel MessageMetadata kinds.

Keep the package tests; never weaken them

When a package test fails, the fix is to update the package source — not to delete the assertion or skip the test. When adding a new nested type to ProviderSnapshotEntry, add it to packages/contracts/src/provider-snapshot.ts — there is no names array to update since provider-types-parity.test.ts was deleted; the single definition is the guard.

What to avoid:

// ANTI-PATTERN: "fixing" a red package test by deleting the assertion, skipping
// the it(), or removing a frame type from the tested set. That converts caught
// drift into a shipped, silent contract break. Fix the package source instead.

Project references:

  • packages/contracts/src/__tests__/ws-frames.test.ts — schema correctness (accept/reject) and the KNOWN_FRAME_TYPES drift probe. (The former server-side ws-frames.ts file mirror parity byte-identical test was deleted when the schema moved to the package.)
  • apps/coder/src/services/__tests__/provider-types-parity.test.tsdeleted: provider snapshot types moved to @boocode/contracts/provider-snapshot.

Additional Resources

Project Documentation

  • BooCoder Dispatch Backends — the provider-snapshot contract and the WS-frame mapping in their runtime context (see "Core Types" and the parity notes).
  • Architecture overview — the three surfaces and the shared database the contracts cross.
  • Root CLAUDE.md → "Conventions" — the cross-app contract rules (WS frame schema, sentinels, provider snapshot types, JSONB) this standard formalizes; updated to reflect @boocode/contracts SSOT.
  • apps/server/CLAUDE.md (services/broker.ts) — broker validation against the @boocode/contracts schema. apps/coder/CLAUDE.mdprovider-types.ts re-exports from the package.

External Resources