CLAUDE.md@src/grove/core/contracts · git:20260803.49581e6 · 2026-08-03 · sha256 fb20fb9246416ee5

CLAUDE.md@src/grove/core/contracts git:20260803.49581e6A

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

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

**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). Per-entry text is capped (`_ENTRY_TEXT_CAP = 4000`, trailing ellipsis is the trim signal) so one mega-turn can't ship a multi-MB body.

**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 **`size_bytes` and `activity` are nullable and mean "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.

**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`) gets its **own**, larger truncation cap (`_FILE_EDIT_TEXT_CAP = 100_000`) because a diff is legitimately bigger than a chat line; `TodoListView`/`TodoItemView` (mirrors `TodoList`/`TodoItem`) **reuses the shared 4 KB `_ENTRY_TEXT_CAP`** on each item's `content`, a todo line being chat-sized. The cap is a per-payload judgement, not a rote 100 K. 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.

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

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

## 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).
## 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+).