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

203 lines
17 KiB
Markdown

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