--- 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/.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 "" 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 "" 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.