---
paths:
  - "apps/server/src/types/ws-frames.ts"
  - "apps/web/src/api/ws-frames.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 a parity test goes red (`ws-frames.test.ts`, `provider-types-parity.test.ts`), a reviewer flags a "half-synced" type, or a frame/sentinel "does nothing" at runtime — i.e. one copy of a duplicated cross-app contract drifted from the other. The fix-it path is [When to Apply](#when-to-apply) + its Verification step.*

- **Status:** proposed
- **Date Created:** 2026-06-02 00:00
- **Last Updated:** 2026-06-02 00:00
- **Authors:**
  - indifferentketchup (samkintop@gmail.com)
- **Reviewers:**
- **Applies To:**
  - Every hand-synced type/schema contract that crosses the `apps/server` ↔ `apps/web` ↔ `apps/coder` boundary in the files under `paths:`. The primary examples are the WS-frame Zod schema, the provider-snapshot types, and the sentinel `MessageMetadata` union plus its `MessageBubble` render arm — but the same rule governs the other duplicated pairs in these files (`WorktreeRiskReport`, the provider-config wire types, and the interface-typed `WsFrame` union that mirrors the Zod schema).

## Introduction

Several wire contracts in BooCode exist as **two or three hand-synced copies** in different apps, because the apps have separate `tsconfig`s with no shared path alias and a composite-project restriction (TS6307) that structurally blocks importing one app's types from another. There is no shared workspace package for these types yet. This standard governs what you must do when you touch one of those copies: **change every copy in the same commit** — and, where the contract has no compile-time consumer guarantee (the sentinel render arm), the consumer too.

The three families in [Coding Standard](#coding-standard) are the primary examples, but the rule applies to **every** hand-synced pair in the files under `paths:`, each of which carries its own in-code `edit both copies` / `Mirror of …` / `KEEP IN SYNC` marker. Beyond the three: `WorktreeRiskReport` (`apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`), the provider-config wire types (`ProviderOverride` / `CoderProvidersFile`, web mirror of the coder's Zod-inferred shapes), and — note this one — a **second** representation of the WS wire shape: the interface-typed `WsFrame` union in `apps/web/src/api/types.ts` plus the `*Frame` interfaces in `apps/server/src/types/api.ts`, which is distinct from the byte-identical Zod `ws-frames.ts` pair and is **not** covered by the byte-parity test. A WS frame's shape therefore lives in more than one place; treat all of them as one contract.

### 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:** two of the three contracts have runtime parity tests (`ws-frames.test.ts`, `provider-types-parity.test.ts`) that catch drift in the test run — but they are a backstop, not the mechanism, and the sentinel triple has no test at all.
- **Side effect:** keeping the copies byte- or text-identical makes a contract 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: edit **every** copy, plus every consumer that switches on the shape, in the **same commit**. If no (a comment or formatting change that the contract's parity test normalizes away) → see "When NOT to Apply".

**Exception — the sentinel/consumer triple:** `MessageMetadata` (`apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`) has **no parity test**, and a new `kind` is inert until it gets a render branch in `apps/web/src/components/MessageBubble.tsx`. When the shape you are editing is `MessageMetadata`, "every copy" includes that render arm — there is no test to remind you.

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

```bash
# The trailing arg is a FILE-PATH substring filter for `vitest run` (not a test
# name). A typo matches zero files and still exits 0 — a false green — so confirm
# the run actually executed the file (look for "1 passed" on the named file).
pnpm -C apps/server test ws-frames.test            # WS-frame byte-parity + KNOWN_FRAME_TYPES drift
pnpm -C apps/coder  test provider-types-parity     # provider-snapshot text-parity (incl. nested blocks)
# Sentinel triple has no test — grep all copies for a NEW rendering kind:
grep -rn "<new-kind>" apps/server/src/types/api.ts apps/web/src/api/types.ts apps/web/src/components/MessageBubble.tsx
```

For a **rendering** sentinel kind (`cap_hit` / `doom_loop` / `mistake_recovery`) the new `kind` must appear in all three files. The non-rendering `error` arm of `MessageMetadata` lives in the two type copies only — it has no `MessageBubble` branch — so for it the grep should match the two `api.ts`/`types.ts` copies, not `MessageBubble.tsx`.

## 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 *text-parity* file.** `provider-types-parity.test.ts` strips comments and blank lines before comparing, so a comment-only change to one provider-types copy is tolerated and you needn't chase the other. (This relief does **not** apply to `ws-frames.ts`, which is compared **byte-for-byte** — every character, including comments, must match.)
- **The shared workspace package lands.** This standard exists *only* because the single source of truth was deferred (a Tier-2 follow-up noted in `provider-types-parity.test.ts`). Once these types move into one shared package, delete the hand-syncing rule rather than keep paying it — the SSOT supersedes this standard.

## Background

The duplication is deliberate, not accidental. A compile-time bidirectional-assignability check was attempted first — a web-side file importing the coder's import-free `provider-types.ts` — but `apps/web/tsconfig.app.json` is a composite project and rejects out-of-include files with **TS6307**, so cross-project type import is structurally blocked. The team chose hand-synced copies guarded by runtime tests over a premature shared package. The WS-frame copies go further and are kept **byte-identical** so a single `readFileSync` equality test can guard them; the provider-snapshot copies are kept **text-identical per named type block** (comments normalized away) because they sit among unrelated types. The cost of this choice is exactly what this standard manages: a copy can drift, and because each app compiles independently, only a runtime test — or a runtime bug — reveals it.

## 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 — `apps/server/src/types/ws-frames.ts` ↔ `apps/web/src/api/ws-frames.ts` (byte-identical):**

```typescript
// PRIMARY: no compile-time link exists across apps (separate tsconfigs, TS6307
// blocks cross-import). A frame type added to one copy but not the other breaks
// silently at runtime — the frontend drops the frame at JSON-parse. So this file
// and apps/web/src/api/ws-frames.ts MUST stay byte-identical, in the same commit.
//
//   IMPORTANT: This file is duplicated byte-identical at
//   apps/web/src/api/ws-frames.ts. ... If you change one, change the other.
//
// Adding a frame also means adding its `type` to KNOWN_FRAME_TYPES (a drift test
// probes every entry for a discriminated branch).
```

**Provider snapshot types — `apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`, text-identical per block.** By convention you author on the coder side and mirror to web (the in-code `KEEP IN SYNC` markers point that way), but the parity test is **symmetric** — it fails on drift in *either* file and names no authoritative copy, so "fix the red test" means re-sync the two, not edit one in particular:

```typescript
// PRIMARY: nothing links these two copies at compile time — a field added here
// but not in apps/web/src/api/types.ts breaks silently at runtime (the web side
// drops or mis-reads the snapshot). The in-file marker, with its test backstop:
//   KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity
//   is enforced by __tests__/provider-types-parity.test.ts (fails on field drift).
// Applies to the nested ProviderModel / ProviderMode / ThinkingOption /
// AgentCommand / ProviderSnapshotStatus blocks the entry references, too.
export interface ProviderSnapshotEntry { /* ...fields... */ }
```

**Sentinel metadata — `apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`, plus the render arm in `apps/web/src/components/MessageBubble.tsx` (no parity test):**

```typescript
// A new *rendering* sentinel kind is a THREE-file change with NO test to catch a miss:
//   1. apps/server/src/types/api.ts  — add the arm to MessageMetadata
//   2. apps/web/src/api/types.ts     — add the identical arm
//   3. 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:**

```typescript
// ANTI-PATTERN: editing one copy only.
// Add a new frame type to apps/web/src/api/ws-frames.ts but not the server copy
// (or vice versa): tsc stays green — they're separate projects — but the parity
// test fails, and had it not existed, the server would publish a frame the
// frontend silently discards at JSON-parse. A half-edited contract is invisible
// to the type-checker; never land one.
```

**Project references:**
- `apps/server/src/types/ws-frames.ts` — the byte-identical sync comment (top of file) and `KNOWN_FRAME_TYPES`.
- `apps/web/src/api/ws-frames.ts` — the web copy that must match it byte-for-byte.
- `apps/coder/src/services/provider-types.ts` — the `KEEP IN SYNC` comment above `ProviderSnapshotEntry`.
- `apps/web/src/api/types.ts` — the provider-snapshot wire copy and the `MessageMetadata` copy.
- `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 the byte-parity test but has no reducer `case` validates and is then silently ignored.

**Correct usage:**

```typescript
// Adding a WS frame type, all in one commit:
//   - apps/server/src/services/inference/turn.ts  — loose InferenceFrame publish union (+ optional fields)
//   - apps/server/src/types/ws-frames.ts          — strict WsFrameSchema + WsFrame + KNOWN_FRAME_TYPES
//   - apps/web/src/api/ws-frames.ts               — byte-identical copy of the strict gate
// The strict web-side type 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.
```

**What to avoid:**

```typescript
// 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:**
- `apps/server/src/services/inference/turn.ts` — the loose `InferenceFrame` publish union.
- `apps/server/src/types/ws-frames.ts` — `WsFrameSchema` (the broker's fail-closed validation gate) + `KNOWN_FRAME_TYPES`.
- `apps/web/src/components/MessageBubble.tsx` — the consumer for sentinel `MessageMetadata` kinds.

### Sync the copies; never weaken the parity test

When a parity test fails, the fix is to make the copies match — not to make the test stop checking. The corollary also holds: when you add a **new** nested type that `ProviderSnapshotEntry` references, add its name to the `names` array in `provider-types-parity.test.ts`, or the new type is hand-synced but **unguarded**.

**What to avoid:**

```typescript
// ANTI-PATTERN: a red parity test "fixed" by deleting the assertion, skipping
// the it(), or trimming a type out of the compared `names` list. That converts a
// caught drift into a shipped, silent contract break. Re-sync the copies instead.
```

**Project references:**
- `apps/server/src/services/__tests__/ws-frames.test.ts` — `ws-frames.ts file mirror parity` (byte-identical) and the `KNOWN_FRAME_TYPES` drift probe.
- `apps/coder/src/services/__tests__/provider-types-parity.test.ts` — text-identity of each shared block across the coder ↔ web copies.

## Additional Resources

### Project Documentation

- [BooCoder Dispatch Backends](../coder-backends.md) — the provider-snapshot contract and the WS-frame mapping in their runtime context (see "Core Types" and the parity notes).
- [Architecture overview](../ARCHITECTURE.md) — the three surfaces and the shared database the contracts cross.
- Root `CLAUDE.md` → "Conventions" — the cross-app contract rules (WS frame, sentinels, provider-type parity, JSONB) this standard formalizes.
- `apps/server/CLAUDE.md` (`services/broker.ts`) and `apps/coder/CLAUDE.md` — per-app notes on the broker validation and the provider-type mirror.

### External Resources

- [Claude Code path-scoped rules](https://code.claude.com/docs/en/memory) — how the `.claude/rules/coding-standards/` index that surfaces this standard is loaded.
