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>
17 KiB
paths
| paths | ||||||||
|---|---|---|---|---|---|---|---|---|
|
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:
- indifferentketchup (samkintop@gmail.com)
- Reviewers:
- Applies To:
- The remaining split contracts that cross app boundaries: the server's
InferenceFrameloose publish union (services/inference/turn.ts), the web's strictWsFramediscriminated union (apps/web/src/api/types.ts), and theMessageBubblesentinel 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.
- The remaining split contracts that cross app boundaries: the server's
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
tscstays green when they drift. The failure surfaces only at runtime, and silently: a WS frame whosetypeexists on one side but not the other is dropped at JSON-parse with no error; a sentinelkindadded without a render arm shows nothing. Editing every copy in lockstep is the only thing that keeps the contract whole. - Secondary: the
@boocode/contractspackage'sws-frames.test.tstests schema correctness (accept/reject behavior) and theKNOWN_FRAME_TYPESdrift probe.provider-types-parity.test.tswas 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, webWsFrame), 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:
- Does this shape exist as a copy in another app? — Check:
grep -rn "<TypeOrFieldName>" apps/*/src. If it appears under two or more ofapps/server,apps/web,apps/coder→ continue. If it lives in exactly one app → see "When NOT to Apply". - 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 sentinelkind. If yes → apply this standard: update the@boocode/contractspackage source, rebuild, and also update every secondary app-side representation (the serverInferenceFrameloose union, the webWsFramestrict union, and theMessageBubblerender 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/contractssource file. The package'sws-frames.test.tstests behavior and structure, not formatting, so a comment-only change does not require secondary app-side updates. - The type lives in
@boocode/contractsand 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 serverInferenceFrameloose union, the webWsFramestrict union, and theMessageBubblerender 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.ts—WsFrameSchema,WsFrame,KNOWN_FRAME_TYPES(via@boocode/contracts/ws-frames). The two former app-levelws-frames.tscopies are deleted.packages/contracts/src/provider-snapshot.ts— provider snapshot types (via@boocode/contracts/provider-snapshot). The former web mirror block inapps/web/src/api/types.tsand the former coder copy inprovider-types.tsare deleted;provider-types.tsnow re-exports from the package.packages/contracts/src/message-metadata.ts—MessageMetadata,ErrorReason(via@boocode/contracts/message-metadata). The former copies inapps/server/src/types/api.tsandapps/web/src/api/types.tsare deleted.apps/web/src/api/types.ts— the web-local strictWsFramediscriminated union (still maintained separately from the canonical schema in the package).apps/web/src/components/MessageBubble.tsx— the sentinel render arms (metadata?.kindbranches).
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
MessageBubblerender arm. A new WS frame additionally needs a runtime handler to do anything:applyFrameinapps/web/src/hooks/useSessionStream.ts(per-session frames) anduseUserEvents(user-channel frames), plus the sidebar reducer. That wiring — and the event-dedup discipline around it — is governed byapps/web/CLAUDE.md, not by this parity standard. A frame that passesWsFrameSchemavalidation but has no reducercaseis 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.ts—WsFrameSchema(the broker's fail-closed validation gate) +WsFrame+KNOWN_FRAME_TYPES.apps/server/src/services/inference/turn.ts— the looseInferenceFramepublish union.apps/web/src/api/types.ts— the web-local strictWsFramediscriminated union.apps/web/src/components/MessageBubble.tsx— the consumer for sentinelMessageMetadatakinds.
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 theKNOWN_FRAME_TYPESdrift probe. (The former server-sidews-frames.ts file mirror paritybyte-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 — 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/contractsSSOT. apps/server/CLAUDE.md(services/broker.ts) — broker validation against the@boocode/contractsschema.apps/coder/CLAUDE.md—provider-types.tsre-exports from the package.
External Resources
- Claude Code path-scoped rules — how the
.claude/rules/coding-standards/index that surfaces this standard is loaded.