CLAUDE.md@src/grove/core/contracts · git:20260908.bbb0fcb · 2026-09-08 · sha256 330c1756d65069ce

CLAUDE.md@src/grove/core/contracts git:20260908.bbb0fcbA

Immutable. This exact content is served forever at /api/v1/blob/330c1756d65069ce.

# grove.core.contracts — the wire boundary (Pydantic shapes that cross clients)

> ↑ [grove.core](../CLAUDE.md) · [root](../../../../CLAUDE.md)

This package is the single coupling point between the engine and every client (TUI, daemon, webapp). It holds the wire-level Pydantic shapes only: request bodies, response Views, and discriminated-union intents. Engine state stays in plain dataclasses elsewhere; nothing here imports a client module, and no client imports the engine's dataclasses directly.

`contracts/` is the canonical home for a wire shape — never import one from a TUI module. The Pydantic-vs-dataclass test itself lives in the root [engineering principles](../../../../CLAUDE.md): would a non-Python client ever construct or receive this?

## Discriminated unions for intent

**Model "what does the user want" as a Pydantic discriminated union, never a dataclass.** `BranchPlan` (`AutoBranch` / `NewNamedBranch` / `ExistingLocalBranch` / `TrackRemoteBranch` / `RootBranch`) is the canonical example: the TUI builds a variant, an API server receives it as JSON, a web client builds it from form fields. Each variant carries a `kind: Literal["..."]` tag; the union is `Annotated[Union, Field(discriminator="kind")]`, so Pydantic v2 dispatches by tag and generates JSON Schema for the whole tree, with `extra="forbid"` catching typos.

Inside the engine each variant has a `resolve(cfg, title, ts) → ResolvedBranch` method; `ResolvedBranch` is a plain frozen dataclass because it never crosses a wire. `RootBranch` (`kind="root"`, no user fields) lives here too — but the placement *gate* that acts on it (skip every worktree side effect, force `delete_branch=False`) is engine policy in [grove.core](../CLAUDE.md). Keep any inline `Annotated[Union, Field(discriminator=...)]` at module scope, never in a closure — the daemon's `TypeAdapter` rebuild can't see closure-scoped types.

**`ticket_order.ticket_sort_key` is a PURE function in contracts because the facts it sorts on only exist AFTER a client-side fetch.** A `TicketRef` is persisted bare; title, state, draft and assignee are an on-demand enrichment GET that is never persisted. So a daemon-computed rank would have to fetch every attached ticket on every workspace read — which is why this follows the `phase_palette.py` precedent (define once, mirror in the webapp, hold with a drift test) rather than the status-blend one (compute once engine-side, ship the answer). It takes kind, state, phase and id as VALUES and reaches for nothing. Two ordering decisions are load-bearing and easy to "simplify" wrongly: **`unknown` state sorts ABOVE settled**, so a ref whose enrichment has not landed does not sink below closed tickets and then visibly jump when it arrives; and **`done` sorts LAST within its group despite being the highest phase index**, because "most progressed" and "most active" agree everywhere except at the end, where they invert.

**The attach plan is the case where the engine union and the wire union are BOTH worth having.** `HostAttach{tmux_session, inside_outer_tmux}` | `ContainerAttach{argv}` (frozen dataclasses in `core/tmux.py`) are mirrored by `HostAttachView` | `ContainerAttachView` under `Field(discriminator="kind")`. The engine pair stays a dataclass because `attach_instruction()` reads `$TMUX` — a host fact `contracts/` has no business touching — and because the two answer subtly different questions: the engine variant's `terminal_argv()` honours `inside_outer_tmux` with `switch-client` (its caller OWNS a terminal that may already be in tmux), while the view's `attach_argv()` never does (a freshly forked pty cannot be). A nullable field on one shape is refused outright: it lets either arm be read with the other's fields. The one wart is that a `type` alias has no class body, so the dataclass→view dispatch is the module-level `attach_instruction_view()` and decoding is the module-level `ATTACH_INSTRUCTION_ADAPTER`; the per-variant field mapping still lives on the variants.

## Views: serialize, never expose

**Every HTTP response shape is a Pydantic View in `views.py`, never the engine dataclass directly.** `WorkspaceStateView` / `WorkspacePeekView` / `AttachInstructionView` / `CommitSummaryView` exist solely to serialize engine dataclasses across HTTP. Each has a `from_*` classmethod that adapts its dataclass; `frozen=True` catches accidental mutation. Internal-only fields (`init_log_path`, `init_env`) deliberately do NOT appear on the wire. New endpoint whose response shape isn't covered → add a View; never widen the wire by leaking a dataclass. Activity wire types (`DashboardEvent` + `*View` mirrors) live in `activity.py` and import the engine activity dataclasses under `TYPE_CHECKING` only, so a contracts import never drags the manager/registry in.

Session-exploration wire types (`SessionSummaryView` / `SessionTurnView` / `SessionDetailView`) live in `sessions.py` — a separate module because history reads are a different concern from the live dashboard. The one *write* shape in that module, `RemapSessionRequest{session_ref}` (the body of `POST /workspaces/{id}/session`), sits there rather than in `requests.py`: it is a session-domain mutation, not part of the create-workspace shape — the same "keep the request beside its concern's views" call `QuestionAnswerRequest` makes in `questions.py`. Resume-into-workspace, by contrast, is a field on `CreateWorkspaceRequest` (`resume_session_id`), since it *is* a create parameter. Two invariants: session turns are **fetch-on-demand only, never embedded in the SSE `DashboardEvent`** (turns are unbounded where the stream payload must stay small), and `transcript_path` stays off the wire (a host-private path; clients identify a session by id).

**Nothing in `sessions.py` trims text, and the three caps that used to are gone.** `_ENTRY_TEXT_CAP` (4000, on entry text / user prompt / compaction summary / todo item / queued message), `_TOOL_BODY_CAP` (16 K, on a tool result and every string inside its request) and `_FILE_EDIT_TEXT_CAP` (100 K, on both sides of a diff) each silently withheld the one thing a reader opened the transcript to read — measured on a live session, a 7092-character assistant turn arrived as 4000 plus `…`. **The bound a client wants is on TURNS (`last=` / `after_turn=`): it asks for that explicitly, it can splice the answer, and it can reason about the unit. A character ceiling is none of those things**, and this route is the only place the complete text exists. The ~1 Hz digest is the surface that pays for brevity — `activity.py`'s `current_task` and the adapters' 200/500-char caps stay exactly as they are, and that asymmetry is the whole design: **cap where the payload repeats per tick, never where the reader went looking for the bytes.**

**Before filling a "not parsed at this scope" field from somewhere else, prove the other source counts the SAME THING — a wrong number is worse than a null, and both shortcuts for `turn_count` were wrong in a way no test would have caught.** `SessionSummaryView.turn_count` is filled at project scope (the listing's parse already produced `activity.human_turns`) and left `None` at catalog scope, and the two rejected ways to fill it host-wide are worth keeping because each looked obviously free:

- **The usage cache's `sessions.turns` is a DIFFERENT QUANTITY, not a stale one.** It sums `role == "user"` over the whole message spine, and `read_messages` includes every sub-agent's sidechain — measured 163 against a true 13 on one real session, disagreeing on 11 of 20 sampled Claude sessions. It agrees perfectly for Codex (no sidechain files), which is exactly how a spot-check blesses it. **A derived cache that already has a column with your field's name is the most dangerous kind of near-miss**, because the lookup is genuinely free (0.6 ms for all 513 rows) and the disagreement only shows against the drill-in's own `total_turns`.
- **A "bounded cheap count" is neither.** Measured over 520 sessions / 1.23 GB: reading the bytes at all is 0.54 s WARM, `json.loads` per line 6.8 s, and the most aggressive byte-prefilter that still yields a number 1.2 s — comparable to the entire cold scan it would bolt onto. And it counts the wrong thing regardless: "which `type:"user"` line is a real turn" is `_Record.is_human_turn`, not a substring test (60 against a true 11 on one session), and Codex rollouts carry no such marker at all, so every one of them counts 0. **Re-deriving a parser's filter as a prefilter re-acquires every trap [agents](../agents/CLAUDE.md) already solved, silently.**

The in-memory `TranscriptCache`/`ResultMemo` is the one source that is always exactly right — it *is* the same computation, invalidated by stat signature — but it is process-lifetime, LRU-bounded, and populated only for sessions the poll or a drill-in already touched: **60 of 520 catalog rows on the reference host, and 0 in a freshly started daemon.** A column that populates non-deterministically per row is not obviously better than one that is honestly empty.

**What finally filled the column is that same computation made DURABLE, and the shape generalizes: when no cheap proxy for a number is honest, stop hunting for one and make the expensive one payable once.** `core/turn_count.py::TurnCountCache` stores `parse_activity(...).human_turns` — byte-for-byte the number the project scope already publishes, so the two scopes cannot disagree — keyed on the transcript's `(mtime, size)`, which is a *complete* change detector rather than a heuristic because transcripts are append-only. The engine side is in [core](../CLAUDE.md); three consequences land here. **The field's nullability is unchanged and still load-bearing** — `None` now also means "not counted YET" (cold cache, a transcript that just grew, a row with no cwd to read it under) and resolves to a number on a later fetch, so a client that renders `0` for null is wrong in a new way. **Nothing moved on the wire**: `turn_count` was already on `SessionSummaryView`, so no schema regenerates and no client changes. And the earlier verdicts above stand entirely — each rejected source was rejected for counting the wrong thing, which durability would not have fixed.

**`human_turns` and `SessionDetailView.total_turns` differ by exactly 1 for a resumed or compacted session** (3 of 40 sampled on-host), because the drill-in renders a leading continuation block that had no human prompt. Naming which one a field counts is the whole obligation; `len(read_turns)` is the other choice and costs a second parse over sub-agent transcripts the listing never reads.

**One view serves two scopes, and that forces two rules.** `SessionSummaryView` backs both `SessionListing` (project scope, a full parse) and `CatalogEntry` (host scope, one bounded head read per session); a second view would make every consumer branch on which listing it fetched to read the same fields. The cost is that the wider scope cannot honestly fill everything, so **`activity` is nullable and means "not parsed at this scope", never "zero"** — a zeroed `AgentActivityView` with `state="unknown"` reads as *measured* data and is the kind of fabrication the catalog's engine seam refuses elsewhere (`project=None`, honest `live`). When a new scope can't fill an existing field, widen the field to null and say so; never synthesize an empty instance. Nullability is the client's signal to hide a column, not to render a 0. `SessionProjectView` mirrors `ProjectContext` and reaches the wire only via `SessionSummaryView.project` — not re-exported, the `FileEditView`/`TodoListView` precedent.

**`size_bytes` is ALSO nullable at catalog scope, but for a different reason than `activity` — worth keeping the two straight.** `activity` is null because the scan structurally refuses the parse that would fill it. `size_bytes` is null only when a filesystem `stat()` genuinely has nothing to report (a remote-backed session with no local file, or a vanished file) — every filesystem adapter's `discover_all` already calls `stat()` per transcript for `mtime`, and `st_size` rides the same `stat_result` at zero extra I/O (`SessionRef.size_bytes`). `SessionSummaryView.from_catalog` reads it straight off `ref.size_bytes`. **One field's nullability is a scope boundary; the other's is an honest "could not measure" — don't collapse the two into one excuse.**

**`UsageQuotasView.accounts` is the selected set, not the discovered set.** Selection belongs to the config cascade because it controls which live subscription credentials may be read. The wire remains a read-only snapshot, including an empty tuple when nothing is selected; clients must not invent a second selection store or interpret empty as measured zero quota.

**The paths rule is about TRANSCRIPT paths; `cwd` is an explicit exception.** `SessionSummaryView.cwd` and `SessionProjectView.repo_root` cross at every scope, because a host-wide row's whole value is *where* a session happened — a row you can't place is a row you can't use — and `WorkspaceStateView.worktree_path` already establishes that a working directory is not the secret. `cwd` is also load-bearing beyond display: it is one third of the `(kind, cwd, session_id)` coordinate the workspace-less turns route resolves by. Populate it at *every* scope: a field that is null in one listing and set in another for no reason is worse than either.

**Structured `DigestEntryView` payloads follow one recipe: one dataclass + wire-mirror pair, one new `DigestEntry.role` literal, zero daemon changes.** `/turns` already serializes `DigestEntryView` generically, so a new optional field rides for free. `FileEditView` (mirrors `grove.core.agents.FileEdit`) and `TodoListView`/`TodoItemView` (mirrors `TodoList`/`TodoItem`) each carried their own per-payload cap, sized by what the payload was — a diff ceiling well above a chat line's. **That per-payload judgement is the thing to NOT re-derive: the answer for every one of them is now "whole", so a new structured payload adds no cap and needs no argument about how big its content usually is.** Neither is re-exported from `contracts/__init__.py` — each reaches the wire only through its `DigestEntryView` field, so the schema pulls it in but consumers never import it directly.

**`ToolCallView` is the recipe's fourth instance and the one that takes NO new role — it rides every entry whatever the role is**, because request/response/duration/running describe the invocation while the other three describe one tool's payload. A client that had to know the role before it could draw a spinner would be branching on the wrong axis. Three rules it settles that the earlier three did not have to:

- **`status: "running" | "ok" | "error"` is a first-class value, and `result: null` is NOT the running signal.** A settled call that returned nothing is also null, so nullness cannot pick a spinner over a check. `tool` itself being null is the third fact — not a tool call, or a provider with no per-call detail — and collapsing any two of the three re-creates the "the degraded answer is indistinguishable from the confident one" bug this file already names twice.
- **Both bodies cross whole, and the `input_truncated`/`result_truncated` pair was DELETED rather than pinned to `False`.** The flags existed because a tool body is the one payload the package's usual trim signal cannot serve — an ellipsis inside a command's own stdout is indistinguishable from output the tool produced, so the cut had to be stated. That argument survives its own conclusion: **the payload whose truncation could not be signalled legibly is the payload that should never have been truncated.** Keeping the fields at a constant `False` would have left every client a standing invitation to render truncation chrome for a transcript that is complete, so they are off the wire; `types.gen.ts` regenerates and the webapp's two "capped by the daemon" notices go with them.
- **The cap that fell hardest was the one whose argument was strongest, and that is the lesson.** `_TOOL_BODY_CAP` was 16 K on MULTIPLICITY rather than size — a turn holds one or two diffs and routinely dozens of tool calls, so it bounded a 40-call turn at ~1.3 MB instead of ~8 MB, and it was applied *through* the request structure so a client still read a field map. Every clause of that is true and it still cost the reader the tail of the build log they opened the turn for. **A cost argument tells you what a bound would SAVE; it never tells you what the reader came for.** Tool bodies were 55.6% of a measured `?last=40` response, which is exactly why they are the thing worth keeping — and the client already has an honest lever for the size, which is asking for fewer turns.

Rider on the SSE rule above: this payload is why the rule holds. Tool bodies are the largest thing a turn carries, and they stay on the fetch-on-demand `/turns` route; nothing about the live pending state moved onto `DashboardEvent`.

**`SessionDetailView._drop_superseded_todos` is the module's ONE surviving reduction, and it survives because it is a PROJECTION rather than a cap.** A todo write is a full-list rewrite, never a diff, so of every `role=="todo"` entry a `/turns` window carries, only the chronologically last one is current — measured on a real 40-turn window: 119 todo entries, 1,100,957 bytes (28.9% of a 3,810,728-byte response), all but one of them pure waste (see [daemon](../../daemon/CLAUDE.md)'s `/turns` section for the surrounding figures). A cap withholds bytes a reader still wants; this withholds none. A superseded board is unconditionally replaced by the next `TodoWrite`, and — since tool bodies stopped being capped — **the bytes are in the response either way, because the same entry's `.tool.input` carries that very `TodoWrite`'s arguments verbatim.** Nulling `.todo` drops a redundant second rendering of a list the response already holds, which is what makes it the one reduction the no-truncation rule does not reach. The method (a static helper on `SessionDetailView`, called from both `from_listing_turns` and `from_catalog_turns` — the daemon route never calls a free function) walks the WHOLE windowed turn list rather than per-turn, because one task-board-driving agent can land dozens of full rewrites inside a single turn. It nulls only `.todo`; `role`/`text` (`TodoList.summary`, a one-line progress digest) survive on every entry, so a client's entry count and position never shift — the same shape `CompactionView` already uses for its own digest projection (payload nulled, role and text kept). "Newest" is selected by POSITION alone, never by content: an agent that clears its board writes an empty list, and picking "the newest non-empty board" instead would silently resurrect a stale one right after the agent explicitly cleared it. No wire-schema change: `todo: TodoListView | None` was already nullable.

**The one payload that DOES ride the stream is the live pane: `DashboardEvent.pane` (a `WorkspacePaneView`) on a `pane_snapshot` frame.** It's the deliberate counter-case to the turns rule — bounded (the capture is `cfg.tmux.peek_history_lines`-deep, not unbounded history) and high-cadence (~1 Hz), so streaming beats fetch-on-demand. It reuses the *same* `WorkspacePaneView` the one-shot `GET .../pane` returns (built via `DashboardEvent.pane_event`), so polling once and consuming the stream yield byte-identical `ansi`/`taken_at` — one view, two transports. The frame rides a **dedicated per-workspace stream**, not the cross-project `/events` fan-out (see [daemon](../../daemon/CLAUDE.md) for why); `pane` is `None` on every non-pane kind.

## `public.py` — the shapes that reach an unauthenticated caller

**Every field is written out by hand, and nothing is derived by subtracting fields from an authenticated view.** That one rule is the module, and the reason is the direction each approach fails in. An allowlist fails CLOSED: a field added to `WorkspaceStateView` tomorrow — another host path, another container identity, another token — is private until somebody types its name into `PublicWorkspaceStateView.from_state`. A denylist ("serialize the state, then drop these five keys") fails OPEN, and it fails *silently*: the leak ships with the change that introduced the field, and no test written today would see it. So the duplication is the feature; `views.py` and `public.py` are meant to be edited separately.

- **`PublicActivityView` is NOT `WorkspaceActivityView` minus a field, and this is where a subtraction would have leaked.** The authenticated activity view EMBEDS a whole `WorkspaceStateView` in `state`, so shipping it publicly hands over every host path this module exists to withhold, through a field nobody inspecting the payload's top level would think to look at. **When judging whether a shape is safe to reuse, expand its nested models — the dangerous field is never the one at the top.**
- **Field NAMES match `WorkspaceStateView` wherever they overlap, deliberately.** The browser renders both payloads through the same components, which is only possible while the public shape structurally satisfies what those components read. Renaming a field here to "make the distinction clear" would fork the UI instead.
- **`PublicWorkspaceView.session_pinned` qualifies the `session_id` already crossing; it does not widen the boundary.** An anonymous reader cannot inspect the record to tell whether that id was frozen when the capability issued or follows the workspace's current primary, and those states change what the page can honestly claim. The field belongs on the public ENVELOPE, not `PublicWorkspaceStateView`: the state allowlist remains a literal inventory of workspace data.
- **`repo_root` does not cross; `project` — the repo directory's NAME — does.** A reader needs to know which codebase they are looking at, and that is a different question from where it lives on somebody's disk. Note this is the one place the "`cwd` is an explicit exception to the paths rule" carve-out above does NOT extend to: that exception is about authenticated host-wide listings, where the whole value of a row is placing it.
- **`TicketRef` crosses as STORED — bare provider/id/kind, with no live resolution.** Enriching it would spend the host's own tracker credential, which means an anonymous request driving an authenticated outbound call and a private tracker's prose landing on a public page. The client half of this is a `repoRoot: null` meaning "do not resolve".
- **`PublicSiblingView` carries other workspaces' tokens, and that is a product decision stated on the contract rather than an oversight.** Sharing one workspace makes every other shared workspace in its repo reachable from it. The containment is structural: an unshared workspace has no token and therefore cannot appear.
- **A LIST'S NAME IS A CLAIM ABOUT MEMBERSHIP, and `siblings` excludes self by definition.** The rail's payload first shipped as `siblings`, self-filtered, on the reasoning that "a list containing the page you are on is a rendering decision no client should re-make". The result was a rail showing every shared workspace in the project *except the one you were reading*: no "you are here", a count off by one against every other surface, and a project with exactly one shared workspace rendering an EMPTY list on a page that self-evidently was one. It is now `shared` and contains everything. **Membership is the server's question; which row is CURRENT is the client's**, and the client already holds the token that answers it — deciding the second question server-side is what cost the first.
- **A cache key that is only unique WITHIN a repo becomes a cross-tenant leak the moment it is lifted out of one.** `TicketRef.key` is `provider:id` because it dedupes refs inside one workspace's list, where the repo is already fixed. A daemon-global ticket cache reusing that key would serve one project's private ticket title to another project's public link — two repos on one forge number their issues independently. The public memo keys on repo root as well; the same question asked one level out found a live instance in the issue-ops publisher. **Ask of any memo in this multi-repo process: is this identity global, or only local to a repo?**
- **The drift guard is a test over `model_fields` asserting the forbidden names are ABSENT**, because nothing in the type system can express "this model must stay small". It is the same shape as the activity/peek superset test — a guarantee no compiler holds needs a test that reads the field set itself.

## A wire shape that is ALSO persisted on a dataclass: `TicketRef`

**`tickets.py` holds `TicketRef` (the provider-neutral ticket shape), `TicketSelector` (`{provider,id,kind}`, reused by create + attach), and `TicketProviderView`.** `TicketRef` is the one case where a Pydantic wire shape is also stored on the engine dataclass (`WorkspaceState.ticket_refs`). It works because the arrow stays one-directional: `tickets.py` imports only pydantic + a `Literal`, and `WorkspaceState` imports `TicketRef` **under `TYPE_CHECKING` only** (the `default_factory=list` needs no runtime symbol; postponed annotations never evaluate `list[TicketRef]`) — the same trick `activity.py` uses to reference engine dataclasses. The store serializes the refs explicitly (`model_dump`/`model_validate`), since `asdict` leaves a nested Pydantic model unserialized. The engine layer ([tickets/](../tickets/CLAUDE.md)) is the mechanism over these shapes.

## A HISTORICAL record takes a plain `str` where a live one takes the closed literal

`history.py`'s `ProgressEntryView.phase` is `str | None`, not `TaskPhase`, and that is the opposite call to `PhaseView.phase` one file over — deliberately. A live claim is written by THIS binary, so the closed literal is exactly right and an unknown value is a bug worth refusing. A recorded claim may have been written months ago by a different Grove: if that version's vocabulary has since lost a member, a closed literal makes `model_validate` reject the row, and because the view holds a LIST, one unreadable row fails the whole read. So the tolerance is at the type, and the client looks the glyph up defensively and renders an unrecognized phase as its own recorded text.

**Generalizable: ask whether a field's writer is guaranteed to be the current binary.** Where it is, close the set. Where the value is durable and re-read later, the set is open whatever today's schema says — the same reasoning `phase.py`'s per-entry tolerant validator applies to one document, applied to a stored one.

## Palettes: a hex contract, and the one that also pins a GLYPH

**`status_palette` / `agent_palette` / `phase_palette` export the dark hex per axis so the TUI imports what the web client mirrors — `runtime_palette` additionally exports `RUNTIME_GLYPH` and `RUNTIME_LABEL`, and the difference is about what a user has to CARRY between clients.** Nobody recites seven agent-state glyphs; they read the label beside them. A runtime mark is two members with no word next to it on the dense surfaces, so the character itself is the vocabulary — and a vocabulary picked independently in two codebases is two dialects. The TUI imports the dicts (drift impossible by construction); the webapp mirrors them under a test that reads *this file* for glyph, label and hex. Do NOT retrofit the other three: hand-mirrored glyphs there are a real but paid-for cost, and moving them now is churn with no new guarantee.

**A palette module is also where a rendering RULE lives when the rule is cross-client.** `runtime_palette`'s docstring carries "both states are marked, deliberately unlike `Placement`'s silence" precisely because the next reader of either client will otherwise re-derive it as a bug — the same reason `phase_palette` explains why it is a ramp rather than seven hues. Glyph choice has one non-obvious constraint worth stating there too: the disjointness that matters spans the CHROME glyphs as well as the axis maps (the obvious `⌂` for a host is already the TUI status bar's repo chip).
## `branch_plan`'s default is NOT the config cascade, and one client already assumed it was

**Omitting an optional field means "resolve it from the cascade at create time" for `model`, `runtime` and `brief` — and means "force `AutoBranch()`" for `branch_plan`.** The asymmetry is invisible from `CreateWorkspaceRequest` alone, because both look like an ordinary default: `runtime: Runtime | None = None` and `branch_plan: BranchPlan = Field(default_factory=AutoBranch)` read the same way. The difference is that **nothing engine-side ever consults `WorkspaceDefaults.branch_mode`** — the TUI applies it *client-side* while building its request (`tui/screens/create.py`), so a client that does not replicate that step silently discards the user's saved branch default.

The failure this produced is the one worth remembering: a create surface displayed the resolved default (`Repo root`, from the user's own config) and, by correctly omitting untouched fields, created an ordinary worktree instead. **Display and behaviour disagreed on the single control that decides whether a workspace is isolated at all** — and every other field on that surface behaved correctly, which is exactly why nobody looked at this one.

So: **a client sends `branch_plan` whenever it shows a branch choice**, touched or not. And if `defaults.branch_mode` is ever to be honoured server-side, that belongs in the engine's create path where every client inherits it, not in a second client copying the TUI.

## A wire rule that was really a WIDGET's rule

**`QuestionAnswerItem` was `indexes XOR text`, and neither half of that was a fact about questions.** It was a fact about the provider's picker: free text existed only as a single-select dialog's synthetic "Type something." row, so it was single-select-only and could not coexist with a choice. Once Grove stopped driving the picker and started restating the batch as prose, both rules evaporated — the model now accepts indexes, text, or **both**, on every kind, and refuses only an item that says nothing at all. `text` gained newlines and tabs for the same reason, while ESC, CR and the rest of C0 stay refused because *those* are terminal control rather than prose.

Two things generalize. **A validator inherited from a rendering will read as a domain rule forever unless somebody writes down which it was** — this one survived long enough that the webapp mirrored it (`acceptsCustomText`) and the docs explained it twice. And **relaxing a validator is the safe direction on a published contract**: every existing client's payload still validates, so no coordination was needed, which is exactly the asymmetry that makes the original over-tightening expensive to notice and cheap to fix.

**The sequel, and it runs the other way: one genuine widget rule survived the purge and had to be given its own KIND rather than a comment.** `plan_approval` joined `AgentQuestionKind` because an `ExitPlanMode` answer is a *position in the agent's own dialog*, not a payload — so unlike everything above, exactly-one-index really is a rule about the domain, and `plan_mismatch` enforces it. It is split out from `confirm` rather than sharing it because **a client must be able to tell them apart before it renders a control**: a kind meaning "optionless yes/no" for one tool and "pick a mode" for another makes the degraded rendering indistinguishable from the right one, which is this file's recurring failure wearing a new hat. **Widening a closed literal is the other safe direction** — an old client cannot receive a kind its daemon never sends, and the set stays closed so an unknown kind remains impossible. `webapp/lib/grove/api/types.gen.ts` regenerates in the same commit, as ever.

## A payload rides the CREATE when the thing it needs does not exist yet

**`CreateWorkspaceRequest.attachments` exists because the landing composer has no workspace id to upload against.** The workspace composer posts to `/workspaces/{id}/attachments` and then names ids on a message; the landing composer's entire action is *"here is a prompt, make me a workspace"*, so the id it would need is minted by the very call it is trying to decorate.

The rejected alternative is the instructive half: create first, then upload, then steer. It trades `initial_prompt`'s **launch-argv delivery for a post-boot type**, which is precisely the boot race that field exists to avoid — and it converts one atomic action into three, where a partial failure leaves a *live* workspace whose prompt names files that never arrived. Carrying the bytes on the create keeps the failure transactional: the engine stores them once the worktree exists and rolls the whole create back if any one of them does not land.

Two rules generalize. **Ask what a request needs that only its own side effect can produce; that is the signal the payload belongs on the request rather than after it.** And **the count needs a bound as much as each item does** — a per-file ceiling says nothing about twenty files in one body, so `max_length` sits beside `AttachmentStore.MAX_BYTES` rather than in place of it.

## Diagram collaboration

`diagrams.py` keeps the persisted descriptor small (relative file path, collaboration identity, mode); XML and its content revision appear only in the authenticated document read. A revision fences stale content, while the collaboration identity fences a late save after stop/reopen even when the file bytes are unchanged. Both preconditions are required on updates and stops. The descriptor is deliberately absent from the public workspace allowlist: adding a private work-panel tab does not authorize publishing its file.

## The gallery: an opaque id, and a `preview_ready` bit the client acts on

`gallery.py`'s `GalleryItemView.id` is a hash of the file's path, not the path — the client never sees or sends one, and `relative_path` is display only. `digest` (SHA-256 of the bytes) is the preview key and the client's staleness signal. **`preview_ready: false` is the ordinary state, not a failure**: it is what tells the browser to render the first page itself and post it back, which is how a gallery of files nobody has opened fills in without a daemon-side renderer. The `workspace_live`/`session_live` pair is what the card's last menu item switches on; both are honest folds of the reconciled status and the catalog's liveness, never fabricated.

## Named terminal input

`keys.py::SendKeysRequest` accepts exactly one `SendKey` and forbids extras; pane targets, command prefixes, literal bytes and repeat counts never cross this boundary. The enum stays in `core.tmux` and is re-exported, not mirrored: `contracts.views` already imports tmux's attach shapes, so moving the enum under contracts would create a tmux → contracts initializer → views → tmux cycle. OpenAPI derives its closed vocabulary from that same enum.

## An OPTIONAL field that emits a `default` generates as REQUIRED

**`Field(default=[])` and `Field(default_factory=list)` are the same thing to Python and different things to the wire.** Pydantic writes `"default": []` into the JSON Schema for the first and nothing at all for the second, and `openapi-typescript`'s `defaultNonNullable` — on by default in v7 — makes **any property carrying a `default` non-optional**, whatever the schema's own `required` array says. So `CreateWorkspaceRequest.attachments` generated as `attachments: AttachmentUploadRequest[]` with no `?`, and an unrelated caller building a request literal stopped typechecking on a field it has nothing to do with.

**Every other optional field on that model escapes this by accident, which is why nobody had met it**: Pydantic emits no `default` key for them at all — `branch_plan` is literally `{}` in the schema despite defaulting to `AutoBranch()`. So the model looked internally consistent while one field behaved differently on the far side of codegen.

Two rules. **Use `default_factory` for an optional collection on any wire model** — it is also the right Python idiom, so the fix costs nothing. And **`required` is not the field that decides optionality in the generated client**; reading the schema's `required` array and concluding the TS will be optional is exactly the wrong inference, which is what made this look like a codegen bug rather than a declaration one. Pinned in `tests/core/test_create_attachments.py` as a SCHEMA assertion, because the schema is the boundary this suite can hold and `openapi-typescript` is not runnable from it.

## Two traps

- **A `from_*` classmethod that constructs a View from a live engine dataclass needs a module-scope import of that dataclass, not a `TYPE_CHECKING` one.** `activity.py` gets away with `TYPE_CHECKING` because it needs the engine dataclass only as a *type*. New contract fields default so old clients decode without a schema bump — a field a wire consumer might not send yet must never be required.
- **Pydantic v2's default regex engine (Rust) has no lookahead / lookbehind.** The natural `^(?!-)[A-Za-z0-9._/\-]+$` for "no leading dash" raises `SchemaError: look-around ... is not supported` at class-construction time. Split the character class instead: `^[A-Za-z0-9._/][A-Za-z0-9._/\-]*$` matches the same set without lookahead. Same trap for any `(?=…)` / `(?<…)` / non-greedy edge case. If a field genuinely needs lookaround, switch its engine via `Field(..., pattern_engine="python-re")` (Pydantic v2.10+).