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>
This commit is contained in:
2026-06-02 17:01:03 +00:00
parent 7ca4a6b344
commit afaca9e426
23 changed files with 1284 additions and 256 deletions

48
apps/server/CLAUDE.md Normal file
View File

@@ -0,0 +1,48 @@
# apps/server — BooChat backend (deep reference)
> Per-app engineering notes for `apps/server/src/`. Cross-cutting commands, database, environment, workflow, and cross-app contracts (WS-frame / provider-type parity, sentinels) live in the **root `CLAUDE.md`**. This file auto-loads when you read/edit files under `apps/server/`.
## Stack
- **Fastify** with `@fastify/websocket` and `@fastify/static` (serves the built frontend).
- **postgres** (porsager/postgres) with tagged-template SQL — no ORM. Schema in `schema.sql`, applied on startup. LSP may false-positive on `sql<Type[]>\`...\`` generics; CLI `tsc` / `pnpm build` is authoritative.
- **Zod** for request validation and config parsing.
## Key services
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn/runInference/createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`, `MAX_STEPS`); `stream-phase.ts` (streamCompletion AI SDK adapter + executeStreamPhase); `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap); `tool-phase.ts` (executeToolPhase → `ToolPhaseResult`; the turn loop lives in turn.ts, not recursion); `sentinel-summaries.ts` (cap-hit/doom-loop/step-cap summaries + inserters); `error-handler.ts` (handleAbortOrError, finalizeCompletion); `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`); `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`); `budget.ts` (resolveToolBudget); `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls); `parts.ts` (`partsFromAssistantMessage`/`partsFromToolMessage`/`insertParts` — parts are the sole source of truth); `prune.ts` (two-tier compaction; `selectPruneTargets` is the pure helper); `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope, reset in `runInference` at the user-message boundary. Outer loop: `while (stepNumber < effectiveCap)`, `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200)`. Per-agent `steps:` in AGENTS.md frontmatter; `steps: 0` = text-only. Step-cap hit writes a `cap_hit` sentinel (`CapHitSentinel.tsx` renders it).
- **AI SDK v6 streamCompletion adapter** (`services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/tests won't catch:
- **Abort signals are swallowed.** `streamText`'s `fullStream` exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required, else the row finalizes `complete` instead of `cancelled`. Don't refactor away the pinning comment.
- **Usage lands only at stream end** via `await result.usage` (v6 `inputTokens`/`outputTokens` → mapped to `promptTokens`/`completionTokens`). No mid-stream tok/s; ChatThroughput shows one value at stream end.
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop — only `description` + `inputSchema: jsonSchema(parameters)`.
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `provider.ts`. The adapter defaults it false → no `stream_options.include_usage` → llama-swap emits no usage block → `result.usage` resolves `undefined` (NULL token counts). Don't remove during refactor.
- **Tool-call-only turns may emit a leading `\n` text-delta.** `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check, else whitespace-only content renders an empty bubble + ActionRow between tool calls. `buildMessagesPayload` also skips `status='failed'` and complete-but-empty assistant rows (avoids "Cannot have 2 or more assistant messages at the end of the list" upstream rejection after cap-hit + Continue).
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart`; BooCode's OpenAI-shape history lacks it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` (v6 `ToolResultOutput`). Reasoning emits a `ReasoningPart` first in the content array.
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
- **Stale-streaming sweeps** (`apps/server/src/index.ts`): a boot-time pass after `applySchema()` and a periodic 60s `setInterval` both flip `messages.status='streaming'` older than 5 min to `failed` (publishing `chat_status='idle'`); the interval also runs `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `onClose` hook clears the timer. Recovers from a container restart mid-stream.
- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema duplicated byte-identical at `apps/web/src/api/ws-frames.ts`; `ws-frames.test.ts` enforces parity. Don't add raw `broker.publish()`/`publishUser()` calls.
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) pass three guards: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). Web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (falls back to `project.default_web_search_enabled`) and filtered out of the LLM tool schema when false. Truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs (`BOOCODE_TRUNCATION_DIR`, default `/tmp/boocode-truncations`, 0o700) keyed by `tr_<12 base32>`; `view_truncated_output(id)` retrieves it. 5MB cap, 7-day TTL, reaped by the sweeper. Container restart loses retrieval — acceptable.
- **`services/compaction.ts`** + **`services/model-context.ts`** — Anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself each compaction). Triggered when `chats.needs_compaction` is set after a turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)`. **`ctx_max` comes from `model-context.getModelContext()` fetching `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx`. First inferences after boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model; negative cache TTL 60s, recovers next turn. `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on assistant `content` (OpenAI wire shape has no structured reasoning field); standalone tag when content is empty. `buildHeadPayload` + `OpenAiMessage` exported for tests — keep them exported.
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. SHA-256 of the assembled prefix is logged per `buildMessagesPayload` (`prefix-fingerprint`, info); a `Map<sessionId, lastHash>` fires `prefix-drift` (warn) on change with a `changed_inputs` diff. The prefix is byte-stable in steady-state, so prefix caching is left to the input-layer mtime caches (BOOCHAT.md + AGENTS.md global/per-project in `agents.ts:safeStat`).
- **`services/inference/budget.ts`** — tool-call budgets: `BUDGET_READ_ONLY = 30`, `BUDGET_NON_READ_ONLY = 10` (forward-looking; no write tools yet), `BUDGET_NO_AGENT = 30` (every `ALL_TOOLS` tool is read-only today, so no-agent shares the read-only cap). Per-agent `max_tool_calls` from AGENTS.md overrides.
- **`messages_with_parts` view** (`schema.sql`). Read sites needing `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` — the legacy `messages.tool_calls`/`tool_results` JSON columns were dropped; the view reads parts-only subselects. Writes target `message_parts` via `insertParts` (or `partsFromAssistantMessage`/`partsFromToolMessage`). The `Message` wire type still carries `tool_calls?`/`tool_results?` because the view synthesizes them. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` (single object), `reasoning_parts jsonb[]` of `{text}`. To UPDATE a message and return its full shape, do a two-step UPDATE returning `id` then SELECT from the view — RETURNING off bare `messages` no longer carries the tool fields. **`messages.model`** (attribution chip) stamps the model per assistant turn — at `finalizeCompletion` (BooChat + native coder) + the dispatcher's assistant-row INSERT (external coder); read via the view + the `message_complete` frame, rendered by `shortenModelName`.
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after the first assistant reply.
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher (in `apps/coder`) picks it up and dispatches via ACP or PTY using the agent's `install_path`.
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)`. Routes live in `routes/*.ts`.
## Server conventions
- **New tools** live in their own `services/<name>.ts` (see `web_search.ts`, `web_fetch.ts`) — a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real deps. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')`.
- **DB/session-aware tools** take an optional 4th `ToolExecCtx { sql, sessionId }` arg on `ToolDef.execute`, plumbed `executeToolPhase``executeToolCall``execute`. Optional so filesystem tools and the `apps/coder` `ALL_TOOLS` consumer stay compatible; filesystem tools ignore it. `read_tab_by_number` is the reference.
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and closes before the consumer reads, so a later `reader.cancel()` finds the stream closed and the `cancel()` callback never fires. Provide MORE chunks than the test consumes so the source stays 'readable' when cancel runs.
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded (this drift class hit `services/agents.ts` `ALL_TOOL_NAMES` before).
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo (removed to eliminate two-files-must-stay-in-sync drift); the `getAgentsForProject` per-project override mechanism remains for *other* projects.
- `data/AGENTS.md` is PARSED (`agents.ts` `splitSections`/`parseAgentSection`): each `## <Name>` is one agent and must be followed by a `---` frontmatter fence or the block throws; content before the first `## ` is discarded. Do NOT add free-form `## ` rule sections — they break the registry. Cross-cutting agent rules go in CLAUDE.md or a parser-ignored preamble.
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. `codecontext/shim.go` is the reference (per the MCP spec, modelcontextprotocol.io/specification/server/transports).
- **`payload.ts:loadContext` SELECT** must include every `Session` field downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. `sql<Session[]>` doesn't enforce column coverage, so the type doesn't catch it.
- **Sidecar routing** (`services/inference/provider.ts`): `upstreamModel(config, modelId, agent)` routes to `LLAMA_SIDECAR_URL` when the agent has `llama_extra_args`, else `LLAMA_SWAP_URL`. `resolveRoute(agent)` returns `{route, flags}`. Sidecar provider created fresh per call (not cached) because `X-Agent-Flags` varies per agent. Boot-time guard in `index.ts` refuses to start if any agent has `llama_extra_args` but `LLAMA_SIDECAR_URL` is unset.
- **Secret guard safe patterns** (`services/secret_guard.ts`): `.env.example`, `.env.sample`, `.env.template`, `.env.defaults` are allowlisted via `SAFE_PATTERNS`. Do NOT add `.env.production`/`.env.development`/`.env.test` — those can hold real secrets.
- **llama-sidecar** (`/opt/forks/llama-sidecar/`): Go daemon for a per-agent llama-server process pool (routed to via "Sidecar routing" above). Cross-compile: `GOOS=windows GOARCH=amd64 /snap/go/current/bin/go build -o bin/llama-sidecar.exe ./cmd/llama-sidecar`. Gitea: `indifferentketchup/llama-sidecar`. Windows child-process gotchas: `context.Background()` for child lifetime (not request ctx), `os.Open(os.DevNull)` for stdin, `os.Pipe()` for stdout with a drain goroutine, `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP` flags. SSH to sam-desktop: `ssh samki@100.101.41.16`; use `schtasks` for persistent spawning (SSH `start /B` doesn't survive session close).

View File

@@ -0,0 +1,93 @@
/**
* Unit tests for `{env:VAR}` substitution in the MCP config loader.
* Pure — no live MCP server. Verifies secrets resolve from process.env
* (so real keys live in `.env`, not the gitignored config file).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { substituteEnvVars } from '../mcp-config.js';
// Minimal FastifyBaseLogger stub — only .warn is exercised here.
function fakeLog() {
const warnings: string[] = [];
const log = {
warn: (msg: unknown) => {
warnings.push(typeof msg === 'string' ? msg : JSON.stringify(msg));
},
};
return { log: log as never, warnings };
}
describe('substituteEnvVars', () => {
const SAVED = process.env.MCP_TEST_SECRET;
beforeEach(() => {
process.env.MCP_TEST_SECRET = 'resolved-value';
});
afterEach(() => {
if (SAVED === undefined) delete process.env.MCP_TEST_SECRET;
else process.env.MCP_TEST_SECRET = SAVED;
delete process.env.MCP_TEST_MISSING;
});
it('replaces a {env:VAR} reference in a string value', () => {
const { log } = fakeLog();
expect(substituteEnvVars('{env:MCP_TEST_SECRET}', log)).toBe('resolved-value');
});
it('substitutes inside nested objects and arrays', () => {
const { log } = fakeLog();
const out = substituteEnvVars(
{
headers: { CONTEXT7_API_KEY: '{env:MCP_TEST_SECRET}' },
args: ['--token', '{env:MCP_TEST_SECRET}'],
},
log,
);
expect(out).toEqual({
headers: { CONTEXT7_API_KEY: 'resolved-value' },
args: ['--token', 'resolved-value'],
});
});
it('leaves object keys untouched, only transforms values', () => {
const { log } = fakeLog();
const out = substituteEnvVars({ '{env:MCP_TEST_SECRET}': 'literal' }, log) as Record<string, string>;
expect(Object.keys(out)).toEqual(['{env:MCP_TEST_SECRET}']);
});
it('resolves an unset var to empty string and warns', () => {
const { log, warnings } = fakeLog();
expect(substituteEnvVars('{env:MCP_TEST_MISSING}', log)).toBe('');
expect(warnings.some((w) => w.includes('MCP_TEST_MISSING'))).toBe(true);
});
it('passes non-string scalars through unchanged', () => {
const { log } = fakeLog();
expect(substituteEnvVars(true, log)).toBe(true);
expect(substituteEnvVars(42, log)).toBe(42);
expect(substituteEnvVars(null, log)).toBe(null);
});
it('leaves strings without a reference unchanged', () => {
const { log } = fakeLog();
expect(substituteEnvVars('https://mcp.context7.com/mcp', log)).toBe('https://mcp.context7.com/mcp');
});
it('resolves multiple references in one string (global flag)', () => {
const { log } = fakeLog();
expect(substituteEnvVars('{env:MCP_TEST_SECRET}/{env:MCP_TEST_SECRET}', log)).toBe(
'resolved-value/resolved-value',
);
});
it('passes an empty string through unchanged', () => {
const { log } = fakeLog();
expect(substituteEnvVars('', log)).toBe('');
});
it('collects unset var names into the optional collector set', () => {
const { log } = fakeLog();
const unset = new Set<string>();
substituteEnvVars({ url: '{env:MCP_TEST_MISSING}', headers: { k: '{env:MCP_TEST_SECRET}' } }, log, unset);
expect([...unset]).toEqual(['MCP_TEST_MISSING']);
});
});

View File

@@ -111,6 +111,19 @@ describe('WsFrameSchema (v1.13.11-a)', () => {
expect(result.success).toBe(true);
});
it('accepts a message_complete frame with a null model (external coder, no model selected)', () => {
// Regression guard: the dispatcher publishes `model: task.model` (string |
// null). When null, this MUST validate or publishFrame fail-closes and drops
// the whole frame, incl. the status:'complete' transition.
const result = WsFrameSchema.safeParse({
type: 'message_complete',
message_id: VALID_UUID_A,
chat_id: VALID_UUID_B,
model: null,
});
expect(result.success).toBe(true);
});
it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => {
// Probe each known type by attempting a minimal valid construction.
// Failure here means the union and the KNOWN_FRAME_TYPES list drifted.

View File

@@ -4,6 +4,12 @@
* Reads a JSON config file (default `/data/mcp.json`) that declares MCP
* servers — their transport type, connection parameters, and enabled state.
* Schema shape matches opencode's `mcpServers` key for copy-paste compat.
*
* Secrets stay out of the config file via `{env:VAR}` substitution
* (opencode-compatible). Any string value can reference an environment
* variable, e.g. a header `"CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}"`
* resolves from `process.env` at load. This keeps real keys in `.env`
* (`env_file` in docker-compose) rather than the gitignored config.
*/
import { readFileSync } from 'node:fs';
import { z } from 'zod';
@@ -38,6 +44,49 @@ export interface McpServerEntry {
config: McpServerConfig;
}
// ---- Env-var substitution ----
const ENV_VAR_PATTERN = /\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g;
/**
* Recursively replace `{env:VAR}` references in string values with the
* matching environment variable (opencode-compatible). Runs before Zod
* validation so a resolved value (e.g. a `{env:...}` URL) still validates.
* An unset var resolves to '' and logs a warning so a missing secret is
* visible in the boot log rather than silently sending a literal placeholder.
* Pass an optional `unsetVars` set to collect the names that resolved to '';
* the loader surfaces them on a validation failure (an empty value in a strict
* url/command field invalidates the whole config — see loadMcpConfig).
*/
export function substituteEnvVars(
value: unknown,
log: FastifyBaseLogger,
unsetVars?: Set<string>,
): unknown {
if (typeof value === 'string') {
return value.replace(ENV_VAR_PATTERN, (_match, name: string) => {
const resolved = process.env[name];
if (resolved === undefined) {
unsetVars?.add(name);
log.warn(`mcp: env var ${name} referenced in config is unset; substituting empty string`);
return '';
}
return resolved;
});
}
if (Array.isArray(value)) {
return value.map((v) => substituteEnvVars(v, log, unsetVars));
}
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = substituteEnvVars(v, log, unsetVars);
}
return out;
}
return value;
}
// ---- Loader ----
/**
@@ -61,9 +110,19 @@ export function loadMcpConfig(configPath: string, log: FastifyBaseLogger): McpSe
return [];
}
const result = McpConfigSchema.safeParse(json);
const unsetVars = new Set<string>();
const result = McpConfigSchema.safeParse(substituteEnvVars(json, log, unsetVars));
if (!result.success) {
log.warn({ errors: result.error.flatten().fieldErrors }, `mcp: invalid config at ${configPath}`);
// Connect the two otherwise-disconnected warnings: an unset {env:VAR} that
// resolved to '' can invalidate a strict field (url/command) and drop the
// whole config, so name the unset vars alongside the validation errors.
const hint = unsetVars.size
? `${unsetVars.size} referenced env var(s) unset & substituted with '' (${[...unsetVars].join(', ')}); an unset {env:VAR} in a url/command field invalidates the whole config`
: '';
log.warn(
{ errors: result.error.flatten().fieldErrors, unsetEnvVars: [...unsetVars] },
`mcp: invalid config at ${configPath}${hint}`,
);
return [];
}

View File

@@ -124,7 +124,11 @@ export const MessageCompleteFrame = z.object({
ctx_max: z.number().int().positive().nullable().optional(),
started_at: IsoTimestamp.nullable().optional(),
finished_at: IsoTimestamp.nullable().optional(),
model: z.string().optional(),
// nullable: external-coder turns carry task.model, which is null when no
// model was selected. This frame is published through the same fail-closed
// publishFrame, so null MUST validate or the entire frame (incl. the
// status:'complete' transition) is dropped.
model: z.string().nullable().optional(),
metadata: OpaqueObject.nullable().optional(),
});