---
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](#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:**
  - 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:

```bash
# 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](#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`):**

```typescript
// 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`):**

```typescript
// 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):**

```typescript
// 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:**

```typescript
// 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.ts` — `WsFrameSchema`, `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.ts` — `MessageMetadata`, `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:**

```typescript
// 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:**

```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:**
- `packages/contracts/src/ws-frames.ts` — `WsFrameSchema` (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:**

```typescript
// 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.ts` — **deleted**: provider snapshot types moved to `@boocode/contracts/provider-snapshot`.

## 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 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.md` — `provider-types.ts` re-exports from the package.

### 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.
