Files
boocode/docs/coding-standards/cross-app-contract-parity.md
indifferentketchup afaca9e426 feat: MCP {env:VAR} key substitution + coder model/tool-result fixes + docs refactor (v2.7.9)
- MCP secrets: substituteEnvVars recursively resolves {env:NAME} in mcp.json string values from process.env before Zod (opencode-compatible); unset -> '' + boot warning, and invalid-config log names the unset vars (an empty {env:VAR} in a strict url/command field invalidates the whole config)
- data/mcp.json now untracked (.gitignore flips !data/mcp.json -> !data/mcp.example.json); tracked template data/mcp.example.json carries "{env:CONTEXT7_API_KEY}"; .env.example documents the key (9 mcp-config tests)
- Coder fix: message_complete frame model widened string -> string|null (server+web ws-frames parity); dispatcher publishes model: task.model at all 4 external completion points — a null model otherwise fail-closed in publishFrame and dropped the whole frame incl. status:'complete' (regression test)
- Coder fix: claude-sdk mapUserToolResults maps user-message tool_result blocks -> terminal tool_update events (completed/failed w/ output) so tool snapshots resolve instead of spinning forever
- Composer: AgentComposerBar drops §9b resumed/history/new chip + token readout, loses flex-wrap so the row stays one line; CoderPane gains a per-chat localStorage agent-config cache (restores last model on reopen) + threads model into the timeline/chip
- Docs: root CLAUDE.md slimmed (~190 lines), per-app refs split to apps/{coder,server,web}/CLAUDE.md; new docs/coder-backends.md, docs/project-discovery.md, docs/coding-standards/ (cross-app-contract-parity); ARCHITECTURE.md links the backends doc

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 17:01:03 +00:00

16 KiB

paths
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 + its Verification step.

  • Status: proposed
  • Date Created: 2026-06-02 00:00
  • Last Updated: 2026-06-02 00:00
  • Authors:
  • Reviewers:
  • Applies To:
    • Every hand-synced type/schema contract that crosses the apps/serverapps/webapps/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 tsconfigs 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 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.tsapps/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.tsapps/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:

# 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.tsapps/web/src/api/ws-frames.ts (byte-identical):

// 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.tsapps/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:

// 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.tsapps/web/src/api/types.ts, plus the render arm in apps/web/src/components/MessageBubble.tsx (no parity test):

// 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:

// 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:

// 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:

// 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.tsWsFrameSchema (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:

// 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.tsws-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 — 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, 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