Skip to content

Latest commit

 

History

History
4587 lines (3978 loc) · 275 KB

package-api.md

File metadata and controls

4587 lines (3978 loc) · 275 KB

Integration contract — Package API and extensions

Authoritative component of the integration contract. Its shared scope and versioning policy apply to every section below.

Event-loop-free worker checks (internal #2416)

The SDK's /internal entry adds probeWorkerHostAsync, requireWorkerHostAsync, prepareWorkerSandboxAsync and launchAgentWorkerAsync. Each one drives the same steps as its existing synchronous form: the same real-launcher probe, scratch git check and bubblewrap setup waits, in the same order, with the same deadlines and refusals. The difference is that it awaits each wait instead of blocking the caller's event loop. workerHostBoundary gains probeAsync, which answers through a replaced probe, so one test double still stands in for both. The synchronous exports are unchanged.

Its launchMaintenanceWorkerAsync (#2790) likewise drives launchMaintenanceWorker's steps: a separate-UID helper worker's setup is awaited instead of blocking. Everything before that first wait, the maintenance admission included, still runs when it is called.

This is internal compatibility wiring under the internal-entry classification below. It changes no ordinary package signature, CLI/MCP/HTTP envelope, frontmatter meaning or credential authority, and requires a patch changeset.

Native maintenance preparation (internal #2300 / #2343)

The SDK's /internal entry adds prepareMaintenanceWorker and prepareMaintenanceGitInput for existing first-party asynchronous host owners. They prepare one concrete retained-stage command and the existing frozen Git adapter; they are not an extension point, arbitrary filesystem reader/writer, wire input or supported package API. Synchronous callers cannot substitute a stale plan for asynchronous metadata acquisition on a protected stage.

The internal runMaintenanceWorker accepts a synchronous host callback after preparation and immediately before launching the requested command. Storage uses it to revalidate current authority and record entry into the operation. Preparation failure or revoked authority cannot be reported as command entry; the callback is not passed to the first-party preparation commands.

This is internal compatibility wiring under the internal-entry classification below. It changes no ordinary package signature, CLI/MCP/HTTP envelope, frontmatter meaning or credential authority, and requires a patch changeset rather than an additive public-contract feature.

Tool execution evidence (internal #2834)

StartTurnRequest adds optional @internal toolExecution(toolUseId, path), a server-only callback a backend calls before a call's tool_result with where that call actually ran: host_bridge (its body ran in the host's handler for its tool), worker_read or worker_write (a first-party file tool read or wrote in a worker that loaded no extension code). It is never serialized into a worker and is not a client, model or permission input. The pi backend reports it from its own record of each call's RPCs, and only for a worker that loads no extensions; the Claude backend does not report it. The UI server uses it to skip the journal observation after a proven read (governed-writable-workers). A backend that never calls it, and every call it reports nothing for, keeps the observation after each result. This is first-party implementation sharing under the internal-entry classification below and requires a patch changeset.

Prepared ordinary inference transport (internal #1665)

The existing WorkerSandbox input adds optional @internal egress, a concrete host-created turn proxy from the SDK's internal startEgressProxy. It is trusted host data, not a wire field, backend extension callback or destination permission. The ordinary Claude turn runner consumes an explicitly supplied proxy for its stored-login and API-billed profiles; the autonomous path retains its existing selection. An API-billed profile's own key is injected by the relay and its worker holds a placeholder. The pi backend consumes it as well: its relay resolves pi's native credential on the host for each dispatch, refuses a command-sourced or unqualified credential before launch, and sends a credential only to its provider's built-in origins or to the exact origins the server's BRAIN_UI_PI_CREDENTIAL_ORIGINS (provider=origin[,origin];...) lists for that provider. Existing callers omitting it keep their current behavior.

The prepared first-party composition uses the native worker's placeholder credential and host-held OAuth resolution on each actual dispatch. Native initialization, subscription/settings checks and actual model/session behavior remain in the ordinary SDK path. Mutable native project/managed settings are observed before a prompt; a retained immutable startup view instead requires its existing manager acquisition and matching kernel checkpoint identity. Cleanup attempts every owned resource and reports any failure.

This member is first-party implementation sharing, with the internal-entry classification below; it does not add a supported public proxy extension API.

CLI provider relay (internal #1673)

@schlessera/brain-ui-sdk/internal adds startProviderRelay, parseCliProviderOrigins, relayedProviderKeys, providerUsage, hostProviderCredential and their types, and the turn proxy gains relayed() beside inference(). They are first-party implementation sharing with no compatibility promise. The server starts one relay per governed or strict turn that has a turn proxy, and one per credentialed maintenance run where the host has managed native storage. A relay sends a key only to that key's built-in first-party origin (GEMINI_API_KEY to https://generativelanguage.googleapis.com, ANTHROPIC_API_KEY to https://api.anthropic.com, OPENAI_API_KEY to https://api.openai.com) or to an exact origin the server's BRAIN_UI_CLI_PROVIDER_ORIGINS (KEY=origin[,origin];...) lists for that key. A custom endpoint or a custom key variable is refused unless listed. The public protocol adds the TurnBudgetCoverage type (wire). The server supplies it automatically for governed and strict Claude turns, and the internal WorkerNoteFacts gains optional egress. A backend sets it only when it actually routed the worker through that proxy, so the capabilities note never claims recorded egress for an unrouted worker. Custom-origin selection and Pi integration are separate from this input. Host-owned exact-origin authorization per credential remains mandatory for custom OAuth recipients. This input neither sanitizes arbitrary unrelated provider/command credentials nor completes the credential capability and maintenance cutover in #1666.

Inference recipient origin (additive, #2695)

AgentBackend adds optional inferenceOrigin(req: { profileId?: string; sessionId?: string }): Promise<string | undefined>. It returns the exact origin (scheme://host[:port]) that an ordinary turn on that profile, or resuming that session, sends inference to through the host inference relay. The server calls it in the trusted host before admission and names the turn's provider policy recipient from it: a built-in vendor origin, or one listed in the server's BRAIN_UI_PROVIDER_RECIPIENT_ORIGINS (name=origin[,origin];...). A backend that omits it, returns undefined or does not run the relay has no proven recipient, so every provider folder stays masked for its turns.

The internal WorkerSandbox gains optional @internal inferenceRecipient ({ name, origin }), and providerMasks.index becomes optional: absent exactly when providerMasks.folders is empty, for a turn whose recipient is allowed every provider folder. A backend that receives inferenceRecipient passes its origin to startInferenceRelay as the new internal recipientOrigin option. The relay then refuses to start toward any other origin, and a turn without a relay refuses to start. The first-party Claude and pi backends implement all three.

Explicit chat deletion (additive, #3230)

@schlessera/brain-ui-sdk/protocol (and the root and ./client entries that re-export it) exports CHAT_DELETION_CAPABILITY ("chatDeletion"), ChatDeletionRequest, ChatDeletionReceipt, ChatDeletionStore, ChatDeletionOutcome, ChatDeletionRetained, ChatDeletionRefusal and ServerChatRetired, now a member of ServerMessage. DraftErrorCode and DraftErrorResponse gain DRAFT_CHAT_RETIRED. @schlessera/brain-ui-sdk/schemas exports serverChatRetiredSchema, and parseServerMessage accepts the frame. A client that switches exhaustively over ServerMessage["type"] needs a case for chat_retired; the host sends it only to connections that declared the capability. In @schlessera/brain-ui-server, WsHost gains optional chatDeletion and the session catalog optional cleanChatStore and unfinishedChatRetirements, host wiring reachable from its exports. Semantics are in the HTTP and wire components.

Native history removal (additive, #3229)

BackendCapabilities adds optional historyRemoval?: boolean, and AgentBackend adds optional removeSessionHistory?(req: SessionHistoryRemovalRequest): Promise<SessionHistoryRemovalResult>, exported from @schlessera/brain-ui-sdk/server with SessionHistoryRemovalRequest ({ operationId: string; sessionId: string; signal?: AbortSignal }), SessionHistoryRemovalResult ({ outcome }) and SessionHistoryRemovalOutcome ("removed" | "partial" | "unknown"). Removal is supported only when the capability is true and the method exists; the conformance suite in @schlessera/brain-ui-sdk/testing checks that they agree. A host refuses deletion of a chat on any other backend before it retires anything; older backends remain valid and simply do not support it.

A host calls it only for a chat its owner explicitly retired, under that retirement's operation identity, as the deletion policy allows. Before its first await the backend fences the session durably and permanently: listSessions omits it, getHistory returns [], and resume, follow-up, archive and restore refuse it, after a restart too. It then waits for the session's running turns and readers to drain and removes every native artifact it owns: live, archived, staged and conflict copies, subordinate and branch files, display and prompt metadata and file observations. Only names derived from the validated session identity are touched; a link, hard-linked file, redirected root or artifact whose own header names another session is left in place.

The outcome is content-free and maps onto the host's per-store ledger: removed only when every owned artifact is confirmed absent and the installed runtime no longer lists the session; partial while owned content may remain, including when signal aborts the caller's wait first (the removal keeps its single owner and finishes after a genuine drain); unknown when an unexpected failure left the state unconfirmed. Every outcome but removed is retried under the same operation. A second operation for the same session, an invalid identity or a redirected tombstone root rejects with BackendRequestError. Previously sent provider bytes are never rewritten, and provider-held copies are outside it. The first-party Claude and Pi backends implement it.

Inbox provenance (additive, #2741)

The intake createInboxIntake returns adds person(input, key, principal): queue intake for a person's arrival through an authenticated route, outside any turn. It records that provenance with a digest of the staged bytes, so a provider-masked autonomous turn may admit the thread; share(...) records the same, and cli(...) records none. InboxBridgeContext adds optional qualifyOrigin(): void, run in each inbox_list and inbox_get transaction before any payload is built; it throws to refuse the read. The server's autonomous turns pass one that revalidates their origin thread's admission.

Disclosure coverage badge (additive, #1846). The kit's DisclosureProps adds optional badge?: { icon: IconName; label: string }. Its compact neutral glyph and accessible label remain outside the truncating summary label, before the chevron. Existing controlled, seeded and uncontrolled behavior is unchanged.

Hygiene card caller copy (additive, 0.44.0, #2837). HygieneCardProps adds optional stateText?: Partial<Record<state, string>> and busyLabel?: string. A named state's text replaces its file default, the fixed entry included; busyLabel replaces the applying label of the commit control. Both are plain inert text, wrapped anywhere and never parsed. An omitted prop or state keeps every existing file default, meaning, state machine, focus and announcement. The kit has no finding-kind prop: callers choose the copy from the action and outcome they hold.

UI-kit enlarge icon (additive #3397)

The semantic IconName gains enlarge: open content in its dedicated full-size viewer. It draws lucide's Expand, the glyph the chat Mermaid block already uses for Open diagram. The framed diagram renderer's header uses it. No existing key, glyph or component changes.

UI-kit Button reach (additive #3252)

ButtonProps.reach?: boolean, when the Button has onClick, adds the kit's .bk-hit class. Under any-pointer: coarse the target then reaches 44px on each axis, centred on the unchanged paint. Without the prop, without a handler or under a fine pointer, a Button renders and hit-tests exactly as before. Callers keep the gap to each interactive neighbour that the design-kit record states. The .bk-hit class is usable on any element with a handler, from the same stylesheet.

UI-kit action accessibility and loading (additive #1756)

ButtonProps.ariaDescribedBy?: string forwards the supplied element IDs to aria-describedby on the button's existing role-bearing element. ContactAction adds optional disabled, ariaDisabled, ariaLabel, ariaDescribedBy and loading fields. Accessibility and disabled fields pass through to the existing Button behavior. ariaDisabled keeps the action focusable; callers must guard activation while work is pending. disabled removes it from the tab order. loading displays a decorative spinner while preserving the action element and its focus; it does not implicitly disable an action or announce progress. The semantic IconKey adds loading. Spinner rotation respects reduced motion.

Discovery expansion is documented in the ui-react guide. Its store state remains behind BrainUiServices.stores, which is explicitly @internal; no new public graph-store signature, wire endpoint or query limit is introduced.

UI-kit collapsible patterns (additive, #3253)

The kit adds two public components; D57 records why each exists.

  • Tray (TrayProps): label (required; what the panel holds and how much), optional icon, swatch (a CSS colour for a leading mark; never the name), open, onOpenChange, maxHeight and children. Its forwarded ref is the native header button. The body stays mounted and hidden while closed.
  • ItemCard (ItemCardProps, ItemCardAction): title (required), optional meta, note, errorCount, open, onOpenChange, actions (label, onSelect, optional disabled and danger), notice and children. Its forwarded ref is the role="button" toggle. errorCount > 0 replaces the meta with the count and draws the card red. Actions render in a separate ⋯ menu at every size; no actions means no menu. The body unmounts while closed.

open and onOpenChange keep Disclosure's three modes exactly: neither is uncontrolled, open alone seeds once, both together hand control to the parent, and onOpenChange fires on every toggle with the value being moved to. DisclosureProps is unchanged; its open summary now also carries aria-controls naming the body. The shared expansion primitive is internal, not exported, and is no extension seam. No CLI, MCP, wire, storage or HTTP shape changes; a minor changeset covers the additions.

UI-kit compact operation status (additive, #3281)

@schlessera/brain-ui-kit exports StatusIndicator and the types StatusIndicatorProps, StatusIndicatorOperation, StatusIndicatorFact and StatusIndicatorState. D58 records the state matrix and the interaction.

  • StatusIndicatorProps: label (what the status is about), summary (the tooltip sentence, repeated at the top of details), title (the details heading), operations and an optional returnFocus callback, used when details close after the trigger has gone.
  • StatusIndicatorOperation: id (the consumer's stable identity), state, stateLabel (the state in the consumer's words), and optional explanation, facts (StatusIndicatorFact: key, value) and actions (a ReactNode of consumer buttons, rendered in details only).
  • StatusIndicatorState: active, queued, success, attention, unknown, failed, refused or idle.

The component is presentational. It derives only the displayed state and the count. The display state is the worst operation's state, in the order refused, failed, unknown, attention, active, queued, success, idle. Two or more operations show a count. An empty list renders a reserved 44px slot without a control, and it is never success. The accessible name is {label}: {lead stateLabel}[; {n} operations]. Details.

The consumer is responsible for authoritative evidence. Nothing in the component infers success from time, animation or response order, and opening details triggers no action. State, freshness, identity, persistence and every action's authority stay with the consumer. StatusDot is unchanged. No CLI, MCP, wire, storage or HTTP shape changes; a minor changeset covers the additions.

Maintenance temporary-storage host configuration (additive #1708)

BRAIN_UI_MAINTENANCE_TEMP_BYTES and BRAIN_UI_MAINTENANCE_TEMP_BUDGET_BYTES each default to 268435456 bytes. Both accept positive, safe, page-aligned integer byte counts; the aggregate must cover one command allowance. These defaults are independent of agent-session concurrency, agent turn temp, runtime state and output staging. Invalid configuration refuses the host edge.

Resolved ServerConfig.maintenanceTemp and CronConfig.maintenanceTemp contain allowanceBytes and budgetBytes. These fields are optional for existing programmatic callers; environment resolvers always supply them, and absent callers retain validated SDK defaults. The runner carries the operator settings into its own control environment, not the scheduled command's authority. Git transport preserves these resolved limits while retaining its separate envelope without a brain mount.

Server and cron admission is atomic in a ledger selected from their canonical configured UI database identity, with that state's existing access authority. Trusted internal maintenance commands propagate this identity through probes, streamed/synchronous work and the separate Git transport; no scheduled command chooses it. Different launcher uids or temporary directories do not split a shared UI-state domain. Standalone SDK calls and in-memory UI configurations without shared persistent state retain a private per-uid /tmp domain. Foreign PID-namespace ownership is conservatively held, not reclaimed as dead. Optional trusted MaintenanceHost data also reaches first-party backend startup and short Git/scratch checks through the existing backend module context and host bridge configuration. It is never selected by a scheduled command or serialized as worker authority. Standalone helpers retain their existing defaults. Exhausted capacity uses the existing worker_state_busy failure class with a maintenance-specific message; nothing ran and no agent runtime state was lost. Filesystem exhaustion is ENOSPC. No new CLI, HTTP or wire envelope is introduced. See maintenance workers for storage and supported-host requirements.

Apify bridge tools (additive, 0.45.0, #3162)

@schlessera/brain-ui-sdk/tool-contracts (re-exported from /server) adds APIFY_TOOL_CONTRACTS, the seven brain_apify_* contracts, with APIFY_OPERATIONS, APIFY_TOOL_NAMES, APIFY_ERROR_CODES, APIFY_GUIDANCE, apifyOperationOf and the ApifyToolCall, ApifyToolResult, ApifyEnvelope, ApifyErrorCode, ApifyOperation and ApifyToolName types. @schlessera/brain-ui-sdk/server adds handleApifyTool(name, input, bridge), which validates the strict input and forwards { tool, input } to the optional new BackendBridge.apify(call); without it the tool refuses as provider_disabled. The host binds principal, session, turn, posture and approvals; a backend sends the parsed input only. Names, schemas and the envelope are documented in the MCP component. Both first-party backends register the tools in every interactive session.

Package entry points

Breaking pre-1.0 export curation (#534, ruled on #343 question 1). Each @schlessera/brain-* package's ordinary entry points (. and its documented subpaths) export only the supported API. That API includes every type reachable through a public signature, exported by name or not, and the documented behavior. A member tagged @internal in its own JSDoc is excluded, as a private member is.

Entry points named /internal (@schlessera/brain/internal, @schlessera/brain/internal/graph, @schlessera/brain/internal/apify (additive, #3162), @schlessera/brain-common/internal/apify and @schlessera/brain-common/internal/apify-operations (additive, #3162), @schlessera/brain-scrape/internal, @schlessera/brain-render-template/internal, @schlessera/brain-ui-kit/internal, @schlessera/brain-ui-sdk/internal, @schlessera/brain-ui-sdk/internal/client, @schlessera/brain-ui-server/internal, @schlessera/brain-geo/internal and the two backends' /internal) are first-party implementation sharing with no compatibility guarantee. Use them only with the same lockstep version, if at all.

Breaking pre-1.0 narrowing (#1053). The surfaces #534 left public because a public declaration reached them, or because their classification was uncertain, follow a maintainer ruling per item:

  • WsHost (@schlessera/brain-ui-server) stays public as the type of BrainUiApp.wsHost, which createApp constructs. Its supported members are the resolved configuration (brainPath, askUserFormLimits, appName, turnTimeoutMs, maxConcurrentSessions, observability), the backend registry and close(). Its connection, client-set, catalog, turn, activity, inbox, draft, label and classification members are @internal, as are WsHostOptions.catalog, classifier, activity, inbox, inboxTools, toolPermissions and drafts. SessionCatalog and createSessionCatalog move to @schlessera/brain-ui-server/internal: a custom session catalog is no longer supported.
  • ui-react's store hooks (useChatStore, useFileStore, useShareStore, useUIStore, useVoiceStore) stay public for deployment shells, typed as ShellStoreHook<View>: a selector, plus getState() and subscribe() on the default root, over ChatShellState, FileShellState, ShareShellState, UIShellState and VoiceShellState (the ui-react README lists their fields). setState is no longer typed. activeChat and anyStreaming take ChatShellState; hasPendingShare takes ShareShellState, and ShareIntakeState is no longer exported. The zustand state shapes (ChatState, UIState …) leave the surface, and so does BrainUiServices.stores (now @internal), which carried them.
  • Kept as documented API: core's repoRelativePathSchema, for module config schemas (canonical consumer configuration); Claude's MEASURED_RUNTIME, the measured Claude Code and agent SDK pair (backend guide); jobs' runScrape, the direct scrape fallback (jobs README).
  • Moved to the owning package's /internal entry: core collectStats and its CollectStatsOptions (BrainStats and StatsThresholds stay public as the result shape); the SDK's bridge-tool handlers (handleAskUser, handleAskUserForm, handleAskUserList, handleAskUserRank, handleGetCurrentLocation, handleQueryActivity, handleRequestImageMask, handleShowBlock, with ImageMaskHandlerOptions and LocationHandlerOptions), wrapCommand and execWrapperSpawnOptions from /server; describeRetry, canonicalModelId and resolveThinkingLevel from every SDK entry (to /internal, and browser-safe to /internal/client); createToolRendererRegistry from /client, pruneStoredShares, ShareTargetError, isShareTargetRequest and handleShareTargetRequest from /share-target (to /internal/client; registerShareTarget and readShareLaunchParams stay); the kit's TOKENS, LIGHT_TOKENS, canvas and their TokenName key type; render-template's applyExportLinkPolicy and protectExportLinkDestinations; geo's MAX_ROUTE_BYTES, in the new @schlessera/brain-geo/internal.

Additive in 0.42.0 (#1703): /server exports handleTurnTemp, TurnTempAccess and TurnTempPurgeRecord for the scoped usage/purge tool. Its input contract is exported alongside the other bridge contracts; the handler's access capability names the admitted allowance, held root, scratch, excluded/kept directories and already-linked answer text. Each purge reports its outcome through the supplied record callback.

Additive: the shared backend toolkit (#1399, ruled on #1345). What both shipped backends need to give the host the same behavior and security posture is public on @schlessera/brain-ui-sdk/server, each declaration tagged @experimental until 1.0: the bridge-tool handlers (handleAskUser, handleAskUserForm, handleAskUserList, handleAskUserRank, handleGetCurrentLocation, handleQueryActivity, handleRequestImageMask, handleShowBlock, with ImageMaskHandlerOptions and LocationHandlerOptions) and BRIDGE_TOOL_POSTURE; the exec wrapper (wrapCommand, validateExecWrapper, EXEC_WRAPPER_ENV, EXEC_KILLER_ENV); the subprocess environment filter (filterSubprocessEnv, parseSubprocessEnvExtra, SubprocessEnvAudience); the lock keys (BRAIN_LOCK_KEY, bashLockKey); describeRetry, resolveThinkingLevel, assertLoadedSdk, rtkRewriteCommand; and the bundled DEFAULT_CONFIRM_BASH_PATTERNS, whose entries may still evolve as a documented user-visible change. Their behavior is unchanged; they leave @schlessera/brain-ui-sdk/internal. Helpers only one backend, or the host and one backend, import stay internal (bashCommand, SUBPROCESS_ENV, execWrapperSpawnOptions, GIT_LOCK_KEY, canonicalModelId). The toolkit record has the inventory.

Additive: ui-react share and stats exports (#1382). For shells that assemble their own surfaces around ui-react, the @schlessera/brain-ui-react root now exports the share pipeline (shareMarkdown, renderBlockHtml, buildDiagramShareOptions, inlineMermaidDiagrams), splitFrontmatter with FrontmatterSplit, and the /stats command's runStats. The project website's demo uses exactly these, so it builds from the public entry instead of from ui-react's source tree.

Additive: AppErrorBoundary (#1377). @schlessera/brain-ui-react exports AppErrorBoundary and AppErrorBoundaryProps (children, plus a reload hook for tests). A shell wraps its whole tree in it, providers included, so a render error outside a page shows a reload screen instead of a blank app. The page-level boundary inside AppShell needs nothing from the shell.

Additive: IconButton and TextButton (#1379). @schlessera/brain-ui-kit exports two native type="button" primitives and their props. Both forward refs and data-* hooks, support native disabled and disclosure ARIA, and use kit focus/hover paint. IconButton supplies mute/danger/overlay tones and 28px small controls that grow to 44px under a coarse pointer. TextButton supplies link/meta/inherit tones, with a 44px standalone target or inline reach. This is a minor addition; existing button APIs are unchanged. Additive: the module-authoring entry @schlessera/brain/module (#1397, ruled on #537). The helpers a module needs to touch the brain's files the way core does, @experimental until 1.0: path containment and atomic writes (safeResolve, resolveWritable, writeFileSafely with WriteFileSafelyOptions, WriteRefusedError, whose code is "EEXIST" when a name may not be replaced); the scratch area (SCRATCH_DIR, scratchName, writeScratchFile, assertScratchWritable, isInScratch, isWriteRefusal, pruneScratch with ScratchReport, ScratchRemoval and ScratchFailure); frontmatter and generated regions (splitFrontmatterBlock, rewriteGeneratedRegion, inertGeneratedText); the taxonomy, corpus walk and index registry (buildTaxonomy, getMarkdownFiles, runRegistry with RegistryRun and RegistryProblem, and ValidationIssue); and completion providers (resolveCompletionProvider, geminiCompletions with GeminiCompletionConfig, GEMINI_FLASH_MODEL). CommandContext gains an optional completions, the brain's completions config block as written, so a module command resolves the configured provider instead of reading core's process context. First-party modules import this entry and never @schlessera/brain/internal. Index reads stay behind ctx.queries and @schlessera/brain/queries. The /internal entry keeps the same names for the first-party packages that still import them.

Breaking before 1.0: strictest path containment answers (0.45.0, #3422). safeResolve(root, rel) and resolveWritable(root, rel) take a path relative to the brain root. They return its canonical path, or null for any of these:

  • an absolute rel, even one inside the root;
  • a .. segment, even one whose result would stay inside;
  • : or \ in a segment, or a NUL;
  • a dangling symlink, wherever it points;
  • a path whose existing components do not all stay inside, such as one that leaves through one link and comes back through another;
  • a root whose realpath fails.

Before 0.45.0, core accepted an absolute path inside the root and a .. that stayed inside. It also followed a dangling link to an inside target and checked only the final path. A caller holding an absolute path passes it relative to the root's realpath.

The CLI keeps accepting absolute path arguments inside the brain (maintainer ruling). Before resolving one, it converts it with the new, additive brainRelativeArgument(root, arg): string, also exported from @schlessera/brain/module for module commands. An absolute argument lexically under the root as given or under its realpath becomes repo-relative (. for the root itself). Any other argument comes back unchanged, so safeResolve still refuses an absolute path outside the brain and one with a .. segment. The converted path still goes through safeResolve, so an absolute path through a symlink that leaves the brain is refused. The arguments it covers:

  • brain read, archive and process <path>;
  • import --stamp;
  • render <path> and --out;
  • eval --out;
  • geo track paths, --compare, --along and map --out;
  • okf export --out;
  • image --out, --ref and --mask;
  • diagram export|build <path> and --out;
  • travel photo --to, and travel route <file> and --to.

Additive frozen configuration root (#2848). @schlessera/brain/module exports configurationRoot(root: string): string. When root equals the trusted worker's BRAIN_WORKER_DATA_ROOT and BRAIN_WORKER_CONFIG_ROOT is set, it returns the configuration root; otherwise it returns root unchanged. Module configuration reads use this view while content reads use the data root. Jobs scoring reads both settings/jobs.json and relative criteria Markdown from this frozen view. This addition ships in a minor.

Additive module state area (0.45.0, #3380). CommandContext gains an optional stateDir(): string. Core's CLI always provides it to a module command: it returns <root>/.brain-ui/modules/<module-name>/, the module's durable state directory, creating it (mode 0700) on first call, together with .brain-ui/modules/.gitignore (*) when that is absent. root is the brain root the CLI resolved, which inside a trusted worker is BRAIN_WORKER_DATA_ROOT. A symlink at .brain-ui, .brain-ui/modules, the module's directory or that .gitignore refuses with ModuleStateError; a module name outside ^[a-z0-9][a-z0-9-]{0,63}$ refuses the same way. @schlessera/brain/module exports the same rule as MODULE_STATE_DIR (".brain-ui/modules"), moduleStatePath(name) (brain-relative, no disk access), moduleStateDir(root, name) and ModuleStateError. The path is part of the module contract: a scheduled module job keeps durable state there, and in a maintenance worker it is published back through output staging after each run like any other brain write, so the next run reads it.

Read-only host writes from a module command (0.45.0, #3380). When a module command throws an EROFS error whose path lies outside the brain root (or has no path), the CLI exits 2 with a ModuleReadOnlyPathError message on stderr instead: it names the path, the module and its state area, and, when BRAIN_WORKER_DATA_ROOT is set, the host execution boundary. An EROFS inside the brain root keeps the existing read_only_brain refusal. No JSON shape changes. The cron runner adds a [cron-run] line and prefixes the recorded error with the same explanation when a worker job exits nonzero with an unexplained EROFS or "read-only file system" in its stderr.

Additive protected scratch retention (#1743). The existing module helper pruneScratch(root, now?, options?) accepts optional options.protectedPaths: readonly string[]. These brain-relative or absolute paths add retention for exact targets, descendants and ancestors during this prune, including empty protected directories. Omitting them preserves the existing age/byte cleanup. They cannot weaken durable conversation pins or explicit pinnedPaths; protected bytes still count toward reported size and overage. Hosted mask applications supply their frozen admitted protection, without reading another policy or changing existing approval authority. This optional module-helper parameter ships in a minor; its public signature report is updated with it.

Overlay, document layers and palette semantics (#1378; pre-1.0 minor). @schlessera/brain-ui-kit exports Overlay, OverlayProps, OverlayVariant, OverlayCloseReason, z and LAYERS. The controlled primitive supplies sheet, dialog, fullscreen and panel variants with dismissal reasons, focus management and inert background. Its optional surfaceRef lets adapters reset destination scroll and focus; non-modal titled panels expose a script-focusable destination heading. Panels keep their header X under closedBy="none"; that value suppresses Escape, native close requests and scrim taps. Sheets and dialogs remove their drawn close control under none. BottomSheet optionally draws a dismissal control. CommandPalette no longer carries dialog semantics (role="dialog" and aria-modal); wrap it in Overlay to supply modal semantics and focus management. It now renders a named group; its pixels and callbacks are unchanged.

Additive: transcript detail linkage (#3179). OverlayProps.id?: string sets the native dialog/panel id so a trigger's aria-controls identifies the activated overlay. Existing callers and dismissal/focus behavior are unchanged. The closed TranscriptQualifiers and TranscriptSymbols components live only on the first-party /internal entry and add no supported extension seam.

Additive: bounded sheet swipe dismissal (#1433). OverlayCloseReason gains "swipe". Callers branching on the close reason must handle "swipe". Existing reasons keep their meaning and all props are unchanged, so this is a minor addition. Touch and pen can dismiss the topmost sheet (or a centred dialog below 900px) from its non-interactive top 56px, only under closedBy="any". Body and mouse gestures never dismiss. The D54 gesture ruling defines thresholds, bounded resistance, cancellation and reduced motion. onClose requests dismissal once; the caller still owns open and the focus policy. Ineligible sheets rubber-band at most 12px without requesting dismissal. Dictation maps a swipe to Stop, keeping the words for review; draining blocks it.

Before 1.0, the versioning rules above apply. From 1.0, removing, renaming or retyping an ordinary export or a type its signatures reach, or changing its documented behavior, requires a major version; additions ship in minors. api-report/ records every entry point's names and every public export's signature with the types it reaches, and the packaging check holds the built declarations to the same names. The removed and moved names, with migration paths, are listed in the boundary decision.

Lineage: this is the public successor of the INTEGRATION.md that lived in the private brain's scripts directory; shapes are unchanged unless marked.

Interactive main-model identity (additive, #2037)

ProviderInfo.model?: string carries only the explicitly configured main-model id. An absent value means the runtime chooses it; a profile id is not a model. The additive server-only BackendActivityEvent variant { kind: "main_model", model: string, sessionId: string } reports the executing main model through the current startTurn bridge. Claude uses root initialization identity, excluding helper/subagent initialization; Pi uses the runtime session's model. Per-model usage never selects the interactive or voice main model.

The host admits the journal before executing the backend. Known configured identity populates admission metadata; runtime identity binds only the correlated active turn's summary. Cancelled/finished turns and stale bridges from resumed turns cannot bind. Concurrent sessions retain independent identity. Subsequent governance events use the identity known at emission. Earlier null snapshots stay null in storage and all reads/exports; binding does not amend or enrich them, and historical turns are never backfilled. Pre-runtime refusals remain unknown unless the resolved profile explicitly supplies a model. Nullable governance field shapes are unchanged. This additive public interface requires a minor release.

Explicit autonomous backend turns (#675)

The optional server-only StartTurnRequest.autonomous request is additive. It contains { origin: "autonomous", persistence: "none", allowedTools: string[], systemPromptAppend: string } selected by server code. It requires the enforced no-grant posture and synchronous BackendBridge.checkpointPermission. It rejects resume, invalid mode declarations and unsupported backends before runtime work. BackendCapabilities.autonomous?: boolean advertises this specific turn support; it does not advertise containment or enable autonomous dispatch.

Ordinary requests retain their persistence and session_info/terminal semantics. Autonomous requests save no SDK session/history and advertise no interactive session. Their runtime id reaches Activity via the additive BackendActivityEvent variant autonomous_identity, and scopes content/result frames internally. Exactly one terminal result ends an established runtime turn; pre-identity failures may end on a bare error. Principal revocation aborts work visibly. No client/model wire payload selects this mode or its authority.

The additive server-only BackendActivityEvent variant autonomous_usage contains { usage: TurnUsage, atToolBoundary: boolean }: cumulative usage of completed top-level responses and whether the restricted sequential runtime is still at that response's tool-execution boundary. It never claims a whole-turn invoice and adds no client frame. Claude counts Messages API message_stop receipts; pi counts completed assistant message_end receipts, excluding aborted and error answers. Server checkpoint settlement uses these receipts as described in the budget guide. Backends without this optional reporting retain conservative missing-receipt settlement.

The additive server-only BackendActivityEvent variant resource_limit (#1660) contains { limits, fatal }: each new host resource-limit hit of the turn's worker, { limit, source, value, events }, reported next to the step that caused it, and once more with fatal: true when a limit ended the worker. Both shipped backends report it and fail such a turn with worker_resource_limit. The host records it on the run's root span and as a resource_limit governance event (#1651) (wire). It adds no client frame.

The additive server-only BackendActivityEvent variants policy_block and policy_confirm (#1661) contain { toolUseId, payload }. The payload names the Bash tool/input, truncation, original policy-file rule index/pattern/reason and captured policy hash; confirmation also records its approved, declined, cancelled or timed-out decision. Both adapters report these through the existing Activity callback. The host supplies turn/session/actor/profile attribution and keeps the validated governance receipt after Activity detail pruning. They add no client frame and confer no execution authority.

The Activity origin vocabulary adds "autonomous" for spans and run rollups. The SDK schemas accept it, and existing origin filtering selects these runs without including interactive or cron work. Runtime identity, usage and principal attribution remain recorded; unknown prices retain their existing unknown meaning. The synchronous synthetic bridge captures escalation data before no-grant denial and aborts without parking. Durable Action transitions, reservation admission, containment and full-system enablement remain separate gated tasks.

Turn-temp promotion (additive, #1655)

StartTurnRequest.turnId?: string is the host's identity for the turn. A backend names per-turn artifacts with it; without it, the backend mints its own. The shipped server sets it on every interactive and voice turn.

Two server-only BackendActivityEvent variants report what happened to a file an answer linked in turn temp, once the worker has exited. promotion carries { source, destination, bytes, originalLink, rewrittenLink }: the file was copied to destination, brain-relative below .brain/scratch/turns/<turnId>/, and the stored answer now links rewrittenLink. Its optional aliases (additive, #1698) lists every other destination the answer wrote for the same file, each as { originalLink, rewrittenLink }. promotion_refused carries { source, originalLink, reason, bytes, limitBytes }, with reason a TurnTempPromotionRefusal (file_too_large, turn_too_large, not_regular_file, symlink, multiple_links, missing, io_error or scratch_unavailable); the answer keeps the link as written. The host stores both as the governance events of the same names (http-api.md, "Governance event kinds"), without aliases, and shows no marker in the chat. For each promotion it sends the turn's clients the rewritten links (wire), so the open conversation matches the stored answer.

Autonomous admission configuration (additive, #678)

ServerConfig.inbox.budget? adds { spendUsd: number, turns: number, emergencySpendUsd: number, emergencyTurns: number, timeZone: string, unpricedUsdPerToken: number }. It remains optional for embedded configurations. resolveServerConfig() supplies defaults of 5 USD, 0 operations, zero emergency capacity, UTC and 0.01 USD per unpriced token. The six BRAIN_UI_AUTONOMOUS_* environment settings and validation are specified in the budget guide. Omission or zero turns cannot enable autonomous dispatch.

The cap governs new admissions against charged spend plus active conservative reservations; observed overruns are retained. It is not a provider-enforced invoice ceiling. One separately dispatched model-bearing operation counts one turn. Actual billing evidence, unknown-cost charging, frozen admission days and crash recovery follow the async decision. The reservation ledger is internal operational storage. Existing HTTP, wire, SDK turn-request, CLI/MCP and content-index shapes remain unchanged; full-v1 containment and system proof still gate production dispatch.

Restricted autonomous containment (additive, #676)

AutonomousTurnOptions.containment?: "restricted" asks a backend to run the turn inside the restricted envelope for untrusted execution: a private network namespace with only loopback, an explicit read envelope instead of the host root, no ambient project configuration, and inference only through a server-owned relay that holds the credential. BackendCapabilities.restrictedAutonomous?: boolean advertises that support and is only meaningful with autonomous. assertTurnPosture gains an optional third argument, supportsRestricted (default false); with it false, a turn that requests containment rejects with BackendRequestError before runtime work rather than running uncontained.

runAutonomousTurn always requests containment and refuses a backend without the capability. Both first-party backends advertise it. A restricted turn also refuses with BackendRequestError when its profile's credential cannot be held by the relay: a Claude subscription profile without CLAUDE_CODE_OAUTH_TOKEN, or a pi profile without an API key for an anthropic-messages, openai-completions or openai-responses provider. A host that cannot establish the envelope refuses with failure.errorClass = worker_host_unsupported. An autonomous request without containment, such as a handoff summary, keeps its existing meaning. No wire, HTTP, CLI or MCP shape changes, and nothing dispatches autonomous work in production.

Interactive priority and cooperative autonomous yield (additive, #687)

KeyedLockAcquireOptions adds optional priority ("interactive" or "autonomous"), signal, onYield(key) and yieldAfterMs. Ordinary callers remain interactive; equal-priority waiters remain FIFO. Interactive waiters precede queued autonomous work. Cancellation removes a waiting acquisition; it never forcibly releases an executing body. createKeyedLock() optionally accepts clock/timer functions for deterministic verification.

AutonomousTurnOptions adds optional synchronous onYield(key), yieldAfterMs and completedToolCalls (readonly { toolName, input } execution receipts). Both first-party backends notify once per turn after continuous same-key interactive contention, checkpoint before abort, then unwind their actual writers. Different keys do not yield. Completed receipts only restrict replay; they confer no capability and never bypass the current authority gate. isCompletedAutonomousToolCall() compares exact JSON calls independently of object property order. A fresh attempt has no runtime transcript replay.

ServerConfig.inbox adds optional maxAutonomousRuns and yieldAfterMs. MAX_AUTONOMOUS_RUNS defaults to two and requires a positive integer; BRAIN_UI_AUTONOMOUS_YIELD_AFTER_MS defaults to 20000 and requires a positive integer below the normal 30000 ms interactive denial bound. Interactive WS capacity remains separately reserved. Pool admission occurs atomically with budget reservation/claim and counts operations, including batches. Yield preserves spent turns and observed cost, releases unused conservative reserves after unwind, and recovers work within its existing attempt limit. Exhaustion leaves failed work and one durable dead-letter Action. Recovery retains checkpoint and completion receipts in the operational UI database.

These server-only additions do not enable production dispatch or change client wire, CLI/MCP, content-index or frontmatter contracts. Cooperative unwind must finish before the interactive wait bound; a lock is never handed to another writer while the first body can still write. Semantically different tool inputs are evaluated as new calls under current authority, not deduplicated effects.

Rail acts and All commands (additive, #944)

@schlessera/brain-ui-kit exports RailAct (icon, label, optional name, effect, cost, why, onClick), and SideRailProps gains two optional props, acts?: RailAct[] and onOpenPalette?: () => void (D52 §1 in the design-kit record). Acts render as a vertical toolbar named Acts, with one roving tab stop. Its accessible names append the effect, the cost and unavailable: {why}. A why makes the act aria-disabled and never invokes it. The collapsed rail omits acts that have an effect, a cost or a why. onOpenPalette renders a button named All commands with aria-keyshortcuts="Meta+K" in place of the passive ⌘K cap. A rail given neither prop renders as before.

Working sessions and the shared composer row (additive, #949)

@schlessera/brain-ui-kit exports ComposerRow (left?, right?), the row between the message area and the composer, and SessionStrip (sessions, now, optional keyboardOpen and formatClock), the row's left half (D52 §3–4 in the design-kit record). A WorkingSession is id, label, state (one of WORKING_STATES: needs-you, failed, unconfirmed, running, queued, unknown, cant-check, done, cancelled) and onOpen, with optional need, outcome, queueNote, reason, startedAt and endedAt. describeWorkingSession(session, now, clock?) returns the WorkingSessionView the strip prints: word, detail, tone, icon and the accessible name. No age or clock time is printed from a null or absent timestamp. ListRowProps gains four optional props, density?: "pill", name, tabStop and onFocus. IconName gains attention, stopped, unheard, working, unproven, unreachable, finished and withdrawn. A ListRow given none of the new props renders as before.

Runtime approval composition (additive, #1141)

@schlessera/brain-ui-kit extends ApprovalCardProps with optional onAlwaysAllow?: () => void, children?: ReactNode, wrapHeader?: boolean and shortcuts?: { allow: string; deny: string }. onAlwaysAllow offers a remembered-grant action only when supplied; the host determines its eligibility. children carries actual tool input or permission details. wrapHeader lets tool names and full targets wrap in the header. shortcuts prints decision hints while preserving the decision's accessible name; it installs no key handler. Existing props and callbacks remain compatible. These additions ship in a minor, with the public declarations recorded in api-report/.

Search result cards (additive, #1138)

SearchResultCardProps in @schlessera/brain-ui-kit gains optional segments?: readonly {text: string; hit: boolean}[], title?: string, type?: string and active?: boolean. Supplied segments replace the single before/highlight/after snippet and preserve each matched span; an empty array clears the snippet. Omitted segments retain the existing single-highlight API. active draws the surrounding list's current keyboard selection. Existing props and their defaults remain compatible.

The Search panel and both backend search-output renderers adopt the card. They clear absent scores and snippets instead of using presentation defaults. The existing search tool and HTTP result schemas are unchanged; pi's formatted output carries no score. Incompatible, ambiguous, clipped and failed tool output retains a readable original-text fallback. File opening uses the current root's authenticated file viewer; unsafe paths have no open action.

Streaming waiting status (approved pre-1.0 break, #1144)

The maintainer-approved minor removes StreamingAnswer's prototype defaults for phase, target, elapsed time, answer text and cost. Callers supply those facts explicitly; omitted facts draw no sample values. Only the phase word is announced in the polite live region, while elapsed ticks and targets remain outside it. StreamingAnswerProps gains optional pulse?: boolean; false selects a static status dot. Hosts retaining the former sample presentation must pass its values explicitly. The runtime uses the component for waiting status while preserving rich thinking/tool/prose rendering and the composer's single Stop; stream and cancellation schemas are unchanged. The minor changeset records this approved migration, and api-report/ records the added prop.

Exec wrapper in app configuration (additive, #1363)

ServerConfig in @schlessera/brain-ui-server gains optional exec?: ExecWrapperConfig ({ wrapper?: string; killer?: string }), and createBrainClient's options gain optional exec. resolveServerConfig(env) always sets exec from that record's BRAIN_UI_EXEC_WRAPPER and BRAIN_UI_EXEC_KILLER. Every brain CLI and repository-script spawn of an app goes through its configuration's wrapper, never the process environment's, so two apps in one process keep separate wrappers. A configuration without exec keeps the process environment's wrapper, resolved once by createApp(); a client built without exec resolves it once at construction. Neither ever spawns unwrapped because the field is absent. A relative wrapper path now refuses at resolveServerConfig() instead of failing each spawn.

Logo assets (additive, #1424, #1425)

@schlessera/brain-ui-kit exports BrandMark (variant?: "mark" | "lockup", size?: number, label?: string), which renders the accepted logo (#1423) inline with the theme's ink and amber tokens. Below 24px the mark uses the small-size master; without a label it is aria-hidden, with one it is role="img" with that name. No prop changes the artwork.

The new React-free @schlessera/brain-ui-kit/brand entry exports BRAND_MASTERS, the file names of the thirteen master SVGs, and BRAND_ASSET_SPECIFIER. Each listed file resolves as @schlessera/brain-ui-kit/brand/<file> through the ./brand/* export, which maps to the published assets/brand/ directory. A listed file's name and presence are the promise; its bytes change only with a newly accepted design.

The entry also exports BRAND_RASTERS, the raster files: favicon.ico (16, 32 and 48px PNG images), apple-touch-icon.png (180px, opaque), icon-192.png, icon-512.png and icon-maskable-512.png (512px, the mark inside the central 80% circle), rendered from those masters, and social-card.png (1200x630) and social-card@2x.png (2400x1260), reviewed raster masters (#1428). The social card was first rendered from a social-card.svg master; that file is removed before any release shipped it. WEB_APP_MANIFEST_ICONS is a web app manifest's icons array over them (src, sizes, type: "image/png", purpose: "any" | "maskable"), WEB_APP_COLORS its theme_color and background_color (#0c1417), and HTML_ICON_LINKS the head <link>s (rel, href, optional type and sizes). Every src and href is a bare brand file name.

Logo in the empty state (additive, #1426)

EmptyStateProps in @schlessera/brain-ui-kit gains optional brand?: boolean. Set, the empty state shows BrandMark on a plain surface tile in place of its icon medallion; icon and the tone's tint are then not drawn. An empty state without it renders as before.

Consumers

Consumer Surfaces used
brain-ui (packages/ui-server/src/brain/client.ts, packages/ui-server/src/core-queries.ts, packages/ui-server/src/cron/emit.ts) CLI --json commands, the content-index query API through an optional core peer (voice keyterms and the knowledge graph), file paths
Coding-agent sessions (MCP) MCP server tools, CLI
Cron on a hosting container brain maintain, module cron entries (brain jobs scrape …)

Model reranker activation

Approved pre-1.0 breaking behavior change (#406): judgment reranking now requires reranker.enabled: true in canonical brain.config.ts or brain.config.json. Omitted or false means off, even with credentials, a provider, --rerank jev, MCP rerank: "jev" or BRAIN_RERANK_MODE=jev. The boolean is runtime validated; provider is optional (default jev). The same setting governs CLI search/context/process, MCP search/context, eval and configured library/backend access. First-party hybridSearch injection (through the unsupported @schlessera/brain/internal entry) also requires SearchDeps.rerankerEnabled: true; provider construction through resolveReranker alone does not activate it. rerankSetup forwards the flag.

Off uses local lifecycle heuristic ordering, or retrieval order for none. An explicit model-mode request warns and falls back; eval refuses disabled flag/environment requests with exit 2 and no score, including context eval. Dry-run previews can be constructed while disabled or keyless and send nothing. Enabled behavior retains missing-key fallback, deadlines, cancellation, exclusions, provider-result validation and degraded warnings. No JSON envelopes, MCP names/schemas or frontmatter fields change.

Migration: add enabled: true to retain former credential-triggered model ordering. Toggling it off preserves provider/model/key-variable/exclusion and bound fields; re-enabling reuses them. New CLI processes reload config; running MCP/embedding hosts restart or rebuild their context from newly loaded config (TypeScript imports require process restart). See configuration and the decision for the retained provider seam and measurement limits.

Asynchronous UI startup

Approved pre-1.0 breaking API change (#286): createApp(options?: CreateAppOptions): Promise<BrainUiApp> from @schlessera/brain-ui-server replaces the synchronous factory. Consumers must await createApp(...) before reading the handle or passing its fetch and websocket to Bun.serve. Startup refusals now reject the promise, so catch them around the awaited call. There is no synchronous compatibility API.

The experimental BackendModule.probeRuntime?(context: BackendModuleContext) from @schlessera/brain-ui-sdk/server now returns Promise<BackendRuntimeReport>. Descriptor authors make their implementation asynchronous and reject when their required runtime is unavailable. The server awaits active backend probes, then the brain CLI version probe, before opening its database or starting application services. Injecting a registry continues to bypass first-party backend probes without explicit backend requirements, while retaining the brain CLI check and configuration validation.

Both built-in version probes run only --version, with a five-second deadline and up to 250 ms of cleanup, subject to event-loop scheduling. At the deadline cancellation sends SIGKILL through killWrapped, including the configured group killer while the command is running. Killer-helper execution is bounded to 200 ms for these probes. Probe settlement never waits indefinitely for exit, inherited output pipes or the helper; incomplete cleanup is reported as unconfirmed, not as proof that work stopped. Turn cancellation retains its existing behavior. Printing a version before timing out does not count as success. Claude runtime failure refuses startup; an unknown or unreadable brain CLI version warns and continues by default, and a known incompatible version refuses. Version report shapes and the default CLI floor are unchanged.

Additive host requirements (#642): CreateAppOptions.versionRequirements accepts optional brainCli and backends[backendId].sdk/runtime full SemVer minima. They cannot weaken package constraints. Validation and requested identity checks precede app resource creation; unsupported injected backend verification refuses startup explicitly. An explicit content-CLI minimum refuses unknown versions and revalidates the actual executable before client and streaming-sync invocations. Backend minima travel through the existing BackendModuleContext.versionRequirements to probes and construction. The shared server SDK helper retains every owner/declaration, upper bounds, OR grouping and normal per-tuple prerelease opt-in. Compatibility remains separate from measured status and index schemas; existing wire/report meanings hold. Backend factories also accept the optional pair and enforce owning SDK requirements on their actual imported copies (#643). Pi's boot entry adds its primary SDK identity without a runtime or measured field: it has no separately spawned executable. Existing Claude report fields retain their meanings. With a Claude runtime requirement, start/resume re-probe the SDK-selected executable and withhold input until the bounded SDK handshake completes. A contradictory later init version ends the turn through the existing error/result envelopes; compatibility adds no wire fields and changes no measured verdict.

HTML renderer budgets

Additive option (#558): linkPolicy?: "visible-destinations" shares the concrete template policy. Omitted preserves existing renderer/CLI behavior. Enabled retains accepted web/mail navigation with validated visible destination text, makes refused/unavailable targets inert and checks final screen/print visibility with document scripts disabled. A destination still obscured/clipped, or beyond the PNG capture bound, rejects rather than emitting an unchecked artifact. PDF checks also cover the finished annotation's own complete tagged disclosure, physical page bounds and a 9-point legibility floor; print clipping or shrinkage rejects. Documents with accepted links also reject if a content security policy blocks the protective stylesheet. Existing render budgets apply. No target is fetched by this policy. The app's POST /api/render always enables it server-side for both formats, including raw/full/bare HTML; request content cannot opt out. Its injected AppRenderer implementations must honor the supplied option's final-visibility check. The request and response envelopes are unchanged.

The deliberately public createRenderer API of @schlessera/brain-render-puppeteer accepts RendererOptions and returns a Renderer: renderPng({ html, width?, linkPolicy? }) and renderPdf({ html, width?, linkPolicy? }) resolve to Buffer; shutdown() is terminal. Width is clamped to 320–4096 CSS pixels. The package README documents the remaining options. RenderOptions, ENV_VARS and EnvVarSpec complete the package's public entry; see package entry points.

Pre-1.0 breaking behavior change (#72): renderTimeoutMs now bounds page creation, setup and PNG/PDF production, excluding queue waiting and browser acquisition. queueTimeoutMs bounds waiting for a concurrency slot; browserTimeoutMs bounds acquiring the shared browser, including a cold launch. Defaults are 30_000, 60_000 and 30_000 ms respectively, so the maximum request duration is their sum (120_000 ms), subject to event-loop scheduling. Budgets are positive integers within the JavaScript timer range. Timeout errors identify queue, browser acquisition or rendering; exact prose is not a machine schema. Expired queue entries are removed and timed-out calls release capacity. Shutdown rejects queued/new calls, drains active phases and bounds browser closure to another 2_000 ms. It therefore completes within browserTimeoutMs + renderTimeoutMs + 2_000, subject to scheduling. Default network denial, disabled scripting and opt-in sandbox removal retain their policy. The rationale and measurements are in renderer-budgets.md.

Scrape address policy and the bounded robots.txt fetch (#2502)

@schlessera/brain-scrape adds ScrapeClientOptions.addressPolicy ("any" | "public-only", default "any") and ScrapeClientOptions.lookup. It also exports the NonPublicAddressError class and the AddressPolicy, AddressLookup and ResolvedAddress types. ScrapeClient exposes a readonly addressPolicy. With "public-only", the client refuses a non-public address before robots.txt, pacing or the request. It checks the initial request, every redirect hop and the robots.txt fetch, whatever respectRobots is set to. Every resolved address must be public. On the native path the connection goes to the checked address, while the original host name travels in Host and the TLS server name. The proxy path cannot pin the address and documents its remaining window in the package README. A supplied robots cache must carry the same policy, or the constructor throws a TypeError.

RobotsCacheOptions gains userAgent, maxBytes (default 1 MiB), maxRedirects (default 5), addressPolicy and lookup, and RobotsCache exposes a readonly addressPolicy. The network robots.txt fetch now sends the client's User-Agent instead of Bun's default. It follows redirects one hop at a time, holding each hop to the policy, and refuses an oversized body. A refusal is a failed fetch and keeps the existing permissive outcome. ScrapeClient and createBrowserSession pass their User-Agent into the cache they construct. Every HTTP redirect to a URL with embedded credentials is now refused. All of this is additive and ships in a minor. Browser navigation has no address policy.

Browser scraping navigation policy

@schlessera/brain-scrape's BrowserSession.load checks robots.txt and applies per-host pacing before dispatching each HTTP(S) main-frame navigation: the initial URL, every redirect target and subsequent navigation during the load. BrowserSessionOptions.userAgent is both the sent identity and the robots matching token, defaulting to the package's identifying User-Agent. Scripts, images, child frames and page-generated API requests are outside these checks and pacing. The existing rule against intentional circumvention still applies; this is not a browser network sandbox.

PageRequest.allowDisallowed is an explicit per-call option, default false, for an owned host or written site permission. It authorizes only the requested URL's original origin, never a cross-origin redirect, and never persists into another call. Ordinary pacing and usable Crawl-delay still apply. HTTP's respectRobots/SCRAPE_RESPECT_ROBOTS opt-out does not disable browser enforcement. No session-wide browser override is provided.

Sessions own a robots cache and limiter by default; optional robots and rateLimiter inputs share the run's objects. ScrapeClient exposes readonly robots, rateLimiter and userAgent for this purpose. runAdapters and the jobs runner share these objects when constructing the browser. A supplied preconstructed BrowserSession retains the policy state chosen by its caller. RateLimiter.acquire(host, delayMs?, signal?) and an injected clock's sleep(ms, signal?) accept cancellation without granting a request or letting another waiter overtake a preceding acquisition.

pageBudgetMs bounds the whole page operation after concurrency admission, including acquisition, policy waits, navigation and extraction. A refusal or timeout rejects the load, closes its page and releases capacity. Jobs report a board whose pages are all refused as not_run with errors and persist failed, using the existing JSON/status shapes above.

This pre-1.0 tightening replaces unenforced browser navigation and the implicit Chrome User-Agent. Consumers needing a disallowed navigation must provide the authorized per-call option; HTTP behavior is preserved. The scraping-politeness record records the approved request boundary and override policy.

Content-index query API

The concrete synchronous @schlessera/brain/queries entry supports readGraphMeta, readGraphClusters, readGraphNeighborhood, readGraphDiscovery, readGraphMaintenance, readLinkWalk, readVoiceVocabulary, listIndexDocuments, findIndexDocuments and readStorageReferences. The additive Storage operation takes {brainPath, path} and returns the common query envelope with {sources: [{path, indexedAt}], truncated}. It limits resolved incoming sources to 200, reads index metadata only, and refuses a symlinked index. Zero observed sources never establishes that a file is unused. Its complete API specification is part of this contract: signatures/reachable exports, validation/defaults/caps/order, detached result and safe error envelopes, feature compatibility, read-only per-operation snapshot lifetime and replacement behavior. Ordinary conditional exports ship TypeScript source, JavaScript and declarations from the same build. Supported result/behavior additions ship in a minor; changes follow this file's versioning policy. The SQL-boundary ruling keeps the direct-SQL guarantees below binding during the separate consumer migrations. No SQL/handle/provider interface is exposed by the query entry.

The pi backend's brain_list and brain_graph use these results in process, without loading executable config or providers for those two reads. Their existing pi text/details envelopes, listing default 20/cap 100, graph default 1/cap 4, direction, ordering and truncation remain unchanged. An absent indexed graph path still returns empty edges and nodes. Index compatibility, WAL snapshot consistency and per-call replacement checks belong to core. Missing indexes retain the brain index instruction; incompatible or corrupt indexes request brain index --force; busy indexes request a retry; unavailable indexes and invalid inputs produce generic errors. Pi throws these sanitized errors through its existing tool executor error path, never SQLite diagnostics. Query input validation follows the linked API specification.

@schlessera/brain/internal is an explicitly unsupported first-party entry, with no compatibility guarantee; use the same lockstep package version. Its archiveDocument, assembleContext, hybridSearch, indexAll, ingest, loadVecSupport and openDatabase exports serve pi's remaining search/context and write implementation. They may accept native handles and are not supported query APIs. This scoped classification added the internal entry and migrated pi's imports; the export curation removed these helpers from the ordinary @schlessera/brain entry, and moved the other helpers pi and the modules share (path safety, scratch, generated regions, taxonomy, context and write helpers) to the same internal entry. Modules now take theirs from the supported @schlessera/brain/module entry (package entry points). Existing direct-SQL guarantees remain binding until the explicit retirement.

UI server optional core peer (breaking host migration, #697)

@schlessera/brain-ui-server reads the knowledge graph and the index-derived voice vocabulary through this entry, never through its own SQL. It declares @schlessera/brain in peerDependencies (*, the lockstep convention) with peerDependenciesMeta marking it optional, so a host that wants the graph view or keyterms must now install @schlessera/brain itself. Install the same lockstep version as the server; the server accepts >=0.40.0 <1.0.0, 0.40.0 being the first release whose ./queries entry ships all six operations it calls. The package ruling approved this as a pre-1.0 break.

The peer is resolved lazily, once per app. Before use, the server checks that the resolved @schlessera/brain/package.json names that package, that its version is in range, that the loaded ./queries entry belongs to the same installation, and that each feature's operations are functions — the five readGraph* reads for the graph, readVoiceVocabulary for keyterms. A brain binary on PATH, handwritten SQL and a per-query subprocess are never substitutes. Published server declarations do not reference core.

When the package check fails, every other server feature is unaffected:

  • All five /api/graph/* endpoints, meta included, answer 503 { "error": "graph_unavailable", "reason": "core_unavailable" } after their existing parameter validation. This is a missing capability, distinct from any index state and never an empty graph.
  • GET /api/voice/keyterms and the session keyterms serve an empty vocabulary, GET /api/voice/overrides keeps the markdown pronunciation overrides, and the degraded result is never written to the keyterm cache; a cache left by an earlier installation is not served while core is unusable. Providers without keyterm support never resolve core.
  • One warning per feature records the failed check (not_installed, identity_mismatch, version_unsupported, operation_missing, load_failed), the required range and any observed version — no paths or native messages.

With usable core, index states keep their established mappings. Graph meta answers 200 for every index state: core's own description of a pre-graph or uncomputed index, and available: false, reason: "schema", schemaVersion: 0 with zero counts for a missing, incompatible or corrupt index. Subgraph modes answer 503 graph_unavailable with reason: "schema" for those, or "not_computed"; an absent or malformed center/root path is 404 not_found. A lock wait is 503 { "error": "index_busy" }; any other failure is a sanitized 500 { "error": "internal_error" }. An index that claims the graph layout without its tables is refused as a whole, so its links-only neighborhood now degrades with the other modes. Voice keeps the missing-index error, degrades an incompatible index to overrides without caching, and reports a corrupt, locked or unreadable index as an error. writeCache no longer persists a degraded result for any caller, including the post-sync rebuild.

openBrainDb, withBrainDb, BrainDbUnavailableError and MIN_BRAIN_SCHEMA_VERSION stay exported for embedders' own readers under the direct-SQL guarantees below; the server's graph and voice paths no longer use them.

Migrating a host: add @schlessera/brain at the server's version to the host's dependencies (bun add @schlessera/brain), reinstall and restart. A host that serves neither the graph view nor keyterm-capable dictation may skip it. A warning naming core.reason at first graph or keyterm use means the installation is absent or skewed.

Extension interfaces

These eleven seams are @experimental until 1.0: breaking changes are minor-version events, announced in the CHANGELOG. Each declaration carries its own @experimental tag. Related experimental declarations include: BackendBridge, BackendCapabilities, StartTurnRequest, RendererPack, SpeechSession, AsrClientOptions, AsrClientFactory, AsrClientRegistry, LiveConversationSession, LiveConversationEvent, ConversationScope, ConversationWorkRef, ConversationWorkResult, AdapterResult, ScrapeContext, RerankCandidate, RerankRequest, Ranked, AdapterStatus, AdapterRunOptions, RunAdaptersOptions, AdapterOutcome and jobs JobAdapter. #343 selects deliberate public signatures, including their reachable types, for 1.0 stability; the package entry points section states the boundary. extending/README.md says what each one swaps.

The client/server wire protocol is a machine contract rather than an experimental extension interface, including when an experimental backend implements it. Its revision negotiation and documented compatibility guarantees follow the contract-versioning rules above.

BackendBridge.pruneScratch?(produced?: readonly string[]): Promise<void> is an optional host hook after a toolkit fallback writes scratch output. produced contains canonical, slash-separated paths relative to the brain. The host must retain these files for that prune pass, in addition to its retained-conversation pins. The mask fallback supplies the mask it just wrote; its adapter-specific reported path is unchanged. An omitted argument adds no producer pins, and the hook retains its non-rejecting behavior. This additive optional parameter does not change the server-owned applyImageMask route.

Interface Imported from
EmbeddingProvider @schlessera/brain
CompletionProvider @schlessera/brain
AgentRunner @schlessera/brain
SkillEmitter @schlessera/brain
Reranker @schlessera/brain
AgentBackend @schlessera/brain-ui-sdk/server
SpeechProvider @schlessera/brain-ui-sdk/server
LiveConversationProvider @schlessera/brain-ui-sdk/server
AsrClient @schlessera/brain-ui-sdk/client
ToolRenderer @schlessera/brain-ui-sdk/client
ContentRenderer @schlessera/brain-ui-sdk/client
SiteAdapter @schlessera/brain-scrape

tests/seam-list.test.ts fails when this table, the one in extending/README.md and the tags in the source disagree.

Dictation speech integration and conformance

The speech stability ruling selects SpeechProvider and AsrClient, including their reachable authoring types, for stability at the actual 1.0 transition. They remain experimental before that transition. The decision preserves dictation's current meaning beside the separately registered live conversation interface; no TTS or speech-side tool/permission authority is added.

CreateAppOptions.speechProvider?: SpeechProvider in @schlessera/brain-ui-server accepts an implementation by value. It takes precedence over automatic Deepgram discovery; an explicit config.voice.provider must equal the supplied id. Without a value, existing built-in rules remain, including opt-in browser speech and failure when no provider is configured. The mounted protected POST /api/voice/session uses the supplied implementation. It validates a nonempty trimmed lowercase id, four boolean capabilities and callable createSession before invoking it. The returned SpeechSession has a string URL, finite nonnegative expiry, optional string token and optional string-record params. Empty URL/zero expiry remain supported for browser/local sessions. Validation, mismatch and provider failures return the existing 500 { error } envelope without fallback. The deprecated token route remains Deepgram-specific.

The route builds keyterms only when supported, snapshots provider identity and capabilities, and publishes VoiceSessionResponse with connection material. The client's matching AsrClientFactory receives that whole session through an AsrClientRegistry; UI applications register on their root's asr before mounting. Unknown ids throw rather than select another client. External ids should be distinct from the UI's installed built-in factories. A provider using a built-in id must remain compatible with that built-in client's protocol. AsrClientOptions, AsrEvent/AsrPartial/AsrFinal, VoiceSessionResponse, SpeechCapabilities and SpeechSession remain reachable public authoring types. The signature inventory includes the client factory and registry.

runSpeechProviderContract and runAsrClientContract are published from @schlessera/brain-ui-sdk/testing with their probe/harness types and the existing injected ContractTestPrimitives. Keyless probes exercise actual session transports, capture/recognizer state and connection closure: complete shapes, supported nonempty keyterms, provider failures, transcript/error callbacks, hard stop, and a buffered final delivered before graceful drain resolves and capture closes. These suites are the conformance floor; an adapter must also cover its protocol-specific authentication, capture and failure behavior. The authoring guide supplies complete public server/client examples and points to executable harnesses and real route/Chrome integration.

Saved-audio transcription (additive, #1021)

The existing experimental SpeechProvider adds optional transcribeRecording({ audio: Uint8Array, contentType: string, keyterms: string[], signal: AbortSignal }): Promise<SavedAudioTranscription>; its result is { text: string }. Implementations may omit it. Implementations that supply it must send unmodified audio server → provider using server-held credentials, reject failures, and perform no automatic retry. They must not resolve empty text or send audio directly from the browser. This adds no provider registry or separate seam. SpeechTranscriptionError(reason, providerStatus?) records an explicit classification; untyped exceptions, lost/unparseable/invalid responses and local timeouts become terminal outcome_unknown. Definitive HTTP error responses are classified from their status: 401/403 authentication, 429 rate limit, 408/504 provider timeout, other 5xx provider error, and other 4xx media/parameter/validation rejection. A contradictory reason cannot make a nonretryable HTTP response retryable. Providers without an HTTP status must supply a truthful definitive reason or use outcome_unknown.

SpeechCapabilities.savedAudio?: boolean is additive. Absent/false means unavailable to clients. The server derives it solely from a callable optional method for discovery, session responses and uploads; a declared flag that contradicts that method is invalid. GET /api/voice/capabilities is protected, read-only, no-store, returns { providerId, capabilities }, and mints no streaming token or provider request. Selection/validation failures retain 500 { error }. Deepgram is the only built-in saved-audio provider; Web Speech omits the method and reports false. Deepgram sends the raw container body to POST /v1/listen with model=nova-3, smart_format=true, mip_opt_out=true, server Authorization: Token …, the original media type and no encoding. Keyterms use a conservative aggregate 500 UTF-8-byte budget including separators, below the provider's 500-token ceiling. No live vendor call is part of the conformance proof.

GET/PUT/DELETE /api/voice/recordings/:recordingId/transcription are supported HTTP operations, specified in the HTTP reference. All require a usable account principal. Agents/system principals are refused; ambient modes use their existing account mapping (proxy users are distinct). The host derives ownership from its existing account key; it never trusts a client account id. The calling principal is re-resolved inside the claim transaction after reading the bounded body. Expired authorization cannot read a completed result. A recording id is host-wide unique, with an immutable verified hash and owning account, rather than partitioned by login principal. Cross-account reads and mutations return 404 without result disclosure or provider dispatch. Reauthentication as the same account reuses the receipt.

RecordingTranscription is { recordingId, sha256: string|null, providerId: string|null, status, attemptId, retryCount, failures, text?, failure?, disposition? }. Status is transcribing | done | failed | outcome_unknown | consumed. Each failure is { reason, retryable, providerStatus? }; reason is provider_error | rate_limit | provider_timeout | media | parameters | validation | authentication | outcome_unknown. failures retains each failed attempt's classification plus its attemptId. retryCount counts explicit retries (0 initially, maximum 3); it and the failure history survive consumption. Disposition is accepted | discarded. Only done carries transcript text. Hash/provider can be null on a tombstone that precedes the first upload. Recording UUIDs are canonicalized to lowercase for every route and receipt, so alternate case spellings share idempotency, account ownership and tombstones. GET returns the receipt or 404.

Only an immediate transaction inserting a claim may dispatch the provider. A repeated same-hash done upload returns the same receipt and text. An in-progress receipt gives 409 transcription_in_progress; a different hash gives 409 recording_hash_mismatch. A failed plain replay gives 409 transcription_failed. PUT ?retry=<failed attemptId> atomically claims a new attempt only for a retryable failure: provider 5xx, 429/rate limit or a definitive provider timeout including Deepgram 504. Each retry is explicitly user initiated. Media, parameter, validation and authentication failures are never retried; they give 409 transcription_not_retryable. Explicit terminal classifications remain terminal even if an adapter attaches a conflicting transient HTTP status; transient classifications cannot override definitive authentication or validation HTTP evidence. A stale token gives 409 transcription_retry_stale; once three retries are used, a current token gives 409 transcription_retry_limit. Simultaneous retries can claim only one attempt. outcome_unknown gives terminal 409 transcription_outcome_unknown, never any retry. A server restart makes any leftover transcribing receipt unknown. Completion updates only its still active attempt, preventing stale replies from overwriting a tombstone. Provider rejection is a 200 receipt with failed, not a lost HTTP response. Errors use { error, message, receipt? }; stored conflict/terminal responses include the receipt. Authentication errors retain the existing guard envelope.

Receipts live indefinitely in the UI's operational database, never brain.db. They outlive login rows and have no TTL or count-based pruning. DELETE erases text but retains a consumed tombstone; delayed PUTs give 410 transcription_consumed. A DELETE before the first claim inserts a hashless tombstone. The guarantee lasts for the lifetime of that operational database; resetting it is the boundary. Recording ids are random client UUIDs.

The paired UI uploads only after the confirm row's Upload and transcribe action, and uses a recording-id Web Lock. Reconnect/reload/sign-in never uploads audio or sends a chat message. Recovery may read GET status; explicit accept/discard may queue a durable DELETE while offline and replay that deletion after reconnect. Success is saved to the owning local partition before review is ready, and audio remains until explicit acceptance/discard. Unassigned recordings require explicit association before upload. A transport loss during partial upload retains the original bytes/hash and asks for fresh consent; an uploaded request with an unconfirmed reply stays awaiting status. Unknown outcomes and exhausted/nonretryable failures keep audio and offer no Retry. The root's existing request injection accepts optional onUploadProgress (callback percentage); its default uses XHR for actual byte-upload progress, with the same account epoch and 401 guards as fetch. Injected transports that handle this callback must report transferred bytes, never fabricated completion.

runSpeechProviderContract adds optional-method probes of unchanged bytes, media type, supported keyterms, nonempty results and rejection. A provider implementing the method supplies recording() transport observations in its keyless probe. Existing providers that omit it remain valid.

SiteAdapter conformance and migration

Under the 2026-09-28 adoption ruling, all ten jobs boards implement SiteAdapter<RawJob> and production jobs scraping uses the shared runAdapters runner. The seam remains experimental until 1.0; the decision records the boundary and alternatives.

  • scrape(ctx, options) returns AdapterResult<T> with items, required status, optional opaque cursor, and errors. AdapterStatus is ok, empty, unparseable or not_run. ok permits partial items with errors; empty requires positive readable-page evidence, never only zero items. Readable but unrecognized pages are unparseable; no readable result is not_run. Jobs retains its existing PageLedger checks and diagnostic text.
  • AdapterRunOptions supplies incremental, previous cursor, queries, proxy and per-site fetch overrides. Adapters forward applicable options per request; one source's options never mutate the shared HTTP client. Jobs preserves explicit board pacing floors; a run may request a longer delay.
  • runAdapters executes in selection order, supplies the shared HTTP client, and supplies browser context only to adapters declaring needsBrowser. Unavailable required transports, thrown options or adapter failures yield not_run without aborting later sources. A browser created by the runner is closed in finally; a caller-supplied session stays caller-owned. Constructed browsers share the HTTP client's robots cache, limiter and User-Agent unless their explicit browser options override them. Browser navigation retains the existing policy.
  • JobAdapter in module-jobs extends the generic contract with source, tier, detail-host allowlists and detail-fetch metadata. Jobs owns cursor/logging, enrichment, scoring and database cleanup. Every selected source still has the existing CLI report row and status/error/cursor semantics above.

The approved pre-1.0 API break requires consumers to add AdapterResult.status, use JobAdapter instead of removed ScraperAdapter, and use generic AdapterResult<RawJob> instead of removed jobs ScrapeResult. Call scrape(ctx, options) instead of bind(ctx).scrape(opts), pass cursor instead of lastCursor, read items instead of jobs, and take source identity from the adapter or runner outcome. ok([]) reports diagnostic unparseable, not confirmed empty. The CLI { report } envelope does not change.

AgentBackend conformance baseline

Additive in #1679: a backend may expose stateRoomAllowanceBytes, its resolved admission allowance for presentation. A resumed ordinary turn may include a StateRoomObservation on its result, using its actual admitted inputs; omission means unknown. The host reports timestamped allowance metadata and the latest observation without widening any capability or grant. The public protocol exports stateRoomWarning and stateRoomCopy so context and UI share the approved single 75% band and factual copy. See the wire contract.

Breaking pre-1.0 hosted pi execution (#1038, isolated-writer ruling on #674). Each ordinary or autonomous pi turn runs its agent session, resource loader, extensions and shell children inside the shared pre-initialization worker boundary. The authoritative brain is read-only there; shell writes land in fresh scratch. BRAIN_ROOT keeps CLI reads rooted in the brain, while CLI writes retain the hosted read_only_brain refusal. write_file, edit_file and curated writes require the server application route and retain its existing approvals.

The CreatePiBackendOptions fields and AgentBackend, frame, session-list and history shapes are unchanged. The documented sessionDir default changes from <brainPath>/.brain-kit-ui/sessions to ~/.local/state/brain-kit/pi/<SHA-256 of canonical brain path>/sessions. Configured session and native pi agent directories must resolve outside the brain and its ancestors; aliases refuse. Move existing session JSONL into the new default or an external configured directory to resume the same ids/history. The server persists ordinary transcripts over pipes and retains committed edit receipts across cancellation; autonomous turns remain nonpersistent. No worker pool, new public option or extension seam is added. Pi still refuses voice turns because it declares no voice membership. This change does not establish #676's complete credential/egress containment or enable autonomous production dispatch.

Every conforming AgentBackend must honor a requested StartTurnRequest.enforceAllowedTools or StartTurnRequest.noGrantSurface, or reject an unsupported restricted turn with BackendRequestError before runtime acquisition, protocol frames or effects. Silently ignoring either is nonconforming. Both inputs remain optional; absent or false retains ordinary turn defaults. This pre-1.0 tightening follows the maintainer's ruling and decision record; it does not freeze all experimental extension interfaces.

Enforcement requires a permission decision for an off-allowlist tool, including runtime auto-approval shortcuts. Such requests carry outsideEnforcedAllowlist: true; a denial prevents the tool body from running and reaches the model as a denial. noGrantSurface is valid only with enforceAllowedTools: true; otherwise the turn rejects before execution. In a valid no-grant turn, both tool grants and command confirmations are denied promptly without an unanswered bridge request, with a named tool error and permission_denied activity evidence.

StartTurnRequest.posture: "voice" (additive, #957) follows the same rule: a conforming backend runs the turn on its declared voice allowlist or rejects it with BackendRequestError before execution; it never runs it on the ordinary allowlist. See Live conversation.

runBackendContract in @schlessera/brain-ui-sdk/testing checks this baseline with a required permission probe and observable tool-body effects. Both first-party backends demonstrate enforcement with keyless scripted runtimes, rather than passing solely by rejecting every restricted turn. runBackendModuleContract checks a defineBackendModule descriptor's nonempty profile parsing and resolution, confirmation-pattern defaults/disablement, typed invalid-JSON failures and occupied-id collisions. A backend that passed the earlier suite may fail these stricter cases; the testing harness's permission member is newly required.

Module manifests are two-phase: defineModule({ name, configSchema?, setup }), where setup(validatedConfig) returns the contribution. The contribution is schema-validated at load — unknown keys are load errors — and module commands receive { root, json, config, taxonomy }, so a command must NOT re-read brain.config itself. The explicit brain travel migrate source-edit job above is an exception: it never computes effective configuration or serializes evaluated TypeScript. See modules.md.

Hygiene review data (additive, #1024)

brain hygiene reconcile joins brain validate's corpus checks into hygiene detection through a published map from each validation rule to a category and ID evidence. It never reads a validation message. The map, the per-category evidence fingerprints and the invalidation rules are defined in the hygiene review decision. Two IDs are fixed for review handlers: a broken wiki-link is hygieneId("broken-link", path, target), whether validation, audit or both report it, and a missing or invalid required frontmatter field (title, type, created, updated, tags) is hygieneId("required-field", path, field).

The markdown under context/hygiene/ gains disposition semantics. Each open entry's generated lines add - **Sources**: and - **Fingerprint**:. dismissed.md holds dismissed entries under ## Dismissed; _index.md's counts add - Dismissed: N. A disposition made by dismiss or snooze writes dismissed-on: or snoozed-on: (an ISO timestamp), an optional reason:, and disposition-fingerprint:, the fingerprint it applies to. When that fingerprint no longer matches, reconcile reopens the finding with an invalidated: receipt. An entry moved by hand has no recorded fingerprint and keeps its earlier behaviour. A date-only until: keeps its meaning, due on that day. These files are the only store of a disposition: brain index --force does not touch them.

brain validate's output and exit status do not change: a dismissed or snoozed error is still reported and still exits 1.

Hygiene selection (additive, #1026)

brain hygiene next --json refreshes the disposable index and reconciles the markdown log using the same validation join and disposition invalidation as reconcile. It selects only currently detected, open, non-informational canonical findings. Unchanged dismissals and future snoozes are excluded; unchanged snoozes return at their exact due instant. Resolved entries stay out unless their problem is detected again and reconciliation reopens them. Informational findings (including TODO/VERIFY) remain in the log and count as informationalNotShown; they do not prevent a review from completing.

The finding retains id, category, path, stable evidence, message, severity, raw urgency, sources, fingerprint, and fingerprintFields. It adds a plain title, line (1-based in the actual file, or null), field (for field findings, or null), excerpt (one line, at most 60 Unicode code points around the first literal evidence occurrence, or null when absent or unreadable), handler (the legacy manual compatibility marker), firstSeen, and the log's invalidation receipt, if present. Reads for excerpts stay inside the brain root. Missing fields are located by field name rather than an invented line. Selection performs no content repair.

Additive in #1475: every selected finding also carries handlers: RepairHandler[] from the existing fixed repair registry (see bounded repair descriptors). Broken links offer link-note and link-text, plus link-suggested only for exactly one deterministic slug, alias or exact-title match. Required fields carry their typed input schema, including configured type enum values and string-array tags. Unsupported findings carry the manual descriptor with its file, line and explanation. This is the complete supported discovery projection; check still returns only its existing single descriptor. Discovery adds no content, repair-receipt or disposition write beyond next's existing index refresh and reconciliation. Identity, fingerprint, ordering, reason and counts are unchanged. Empty-backlog and configuration/check-blocker envelopes are unchanged and do not carry a handler array.

Ordering is lexicographic: severity rank, urgency rank, oldest firstSeen, then canonical ID in code-unit order. Ranks are ordinals, never weighted scores. The most severe contributing source determines severity; categories and model judgments assign no priority.

Source input Severity class Rank
Validation level: error; audit/module/extra severity: error error 0
Validation level: warning; audit/module/extra severity: warning warning 1
No source severity (including silent edits/index table lag) unknown 2
Audit/module/extra severity: info info 3; counted, not selected
Explicit source urgency Rank
immediate 0
overdue 1
due 2
upcoming 3
Absent, unknown, or any unsupported value 4 (unknown)

Urgency is known only from a candidate's explicit urgency input (currently --extra candidates); no deadline, category, message, age or snooze date is used to infer it. If canonicalization joins multiple source urgency values, the highest recognized urgency wins; unsupported values contribute unknown. Raw urgency remains available separately on the finding. Missing first-seen sorts after dated peers; its age is null. Future dates have age zero.

priorityReason is { severity: "error"|"warning"|"unknown"|"info", urgency: "immediate"|"overdue"|"due"|"upcoming"|"unknown", ageDays: number|null, newerWithSameRank: number, tieBreak: "only-finding"|"severity"|"urgency"|"age"|"identity" }. Age is whole elapsed 24-hour periods since the first-seen UTC day. newerWithSameRank counts eligible peers with the same severity and urgency ranks and a later first-seen date. tieBreak names the first differing comparison against the runner-up, or only-finding when there is none. Neither field changes priority.

Counts are durable backlog totals after reconciliation, including the selected finding in eligibleRemaining. fixed counts resolved log entries (including auto-disappeared); it is not a session counter or proof of a handler's successful repair. dismissed and snoozed count entries in those states. nextSnoozeDueAt is the earliest known snooze instant, or null; unknown due times remain snoozed and do not invent a date. informationalNotShown counts currently detected open informational findings, once per canonical finding.

A configuration blocker has the loader's message, the detected config path (or null), and line/column only when the original error identifies that configuration file; loader implementation stack locations are excluded. It never returns an empty-backlog receipt and writes neither the log nor the index. Only hygiene next tolerates an invalid config; reconcile, dispositions and list retain their refusal. With no configuration file, next retains the CLI's missing-config write refusal. A check blocker lists unavailable checks; reconciliation still preserves unseen entries under its existing failure rules, and selection never claims completion from incomplete detection.

Media awaiting review (additive, #2780)

One media inventory feeds brain sync's count and hygiene review. It holds two populations: the working tree's uncommitted MEDIA and LARGE files as brain sync assess classes them (never Git-ignored or media.ignore files), and the regular MEDIA/LARGE files under .brain/scratch/ that the scratch pin snapshot lists, even when media.ignore matches them. The scratch area is never walked. At most BRAIN_MEDIA_PINNED_PATH_LIMIT (5,000) distinct pinned paths are read per inventory. A missing, unreadable, redirected or malformed snapshot admits no scratch file (links.state: "unknown"). A valid snapshot whose scan is not finished (ready: false) admits its listed paths ("incomplete"). Neither state, nor tooLarge, ever claims that every conversation link was checked.

Each file is one finding of category media, identified by its exact raw repo-relative path, with source hygiene/media (informational) and/or hygiene/large (warning). A file reached only through a pin has hygiene/scratch-media/scratch-large instead: it appears in review and never in the count. Its fingerprint fields are sha256, bytes, classes, policy (the matching media.track/media.ignore entries), refs, pins (the sorted session keys whose pin list holds that exact path, so an unrelated conversation never changes it), severity and urgency. refs is the reference-inventory digest (#2781): none when no note references the file, a 16-hex digest of each reference's note, form and destination as written (line numbers left out), or too-large/unscannable when the bounded scan was refused. One scan of the brain's *.md notes outside hidden directories and context/hygiene/ finds Markdown inline links and images, <…> destinations, reference definitions, wiki links and embeds, frontmatter values and HTML src/href/poster; code is never a reference. Its bounds are BRAIN_MEDIA_REFERENCE_LIMITS (notes 50,000, noteBytes 256 MiB, perNote 4 MiB, scanTime 20 s, refsPerFinding 500; and for an effect's plan, rewrittenNotes 200 and preview 1 MiB, #2781). Replacing the earlier unscanned value reopens every media disposition once.

A fifth log state, kept, holds a media original whose permanent copy was made, in context/hygiene/kept.md under ## Kept as a permanent copy. Each entry records kept-on:, copy:, copy-sha256:, journal: and disposition-fingerprint:. It holds while the original's fingerprint is unchanged and the copy is a contained regular file outside .brain/ and workspaces/ that media.track keeps. Otherwise reconcile reopens the original with an invalidated: … kept on <day> · changed: <fields> receipt, naming copy-missing, copy-untracked, kept-incomplete or the fingerprint fields. Editing the copy keeps it kept. brain hygiene keep-copy writes the entry through reconcile's expected-fingerprint request, refusing a copy that would not hold at once. A pruned original moves the entry to resolved.md with resolved-by: original-pruned. kept.md is written only once it holds an entry, and _index.md adds - Kept: N only while it exists. An existing brain's hygiene files therefore stay byte-identical.

The count is mediaReviewCounts, read-only and shared by both callers. It does not trust the file an entry sits in:

Counted media finding's log state awaitingReview Set aside / kept
open, resolved, or no entry yes —
dismissed, fingerprint unchanged (or none recorded) no setAside
snoozed, fingerprint unchanged and now < dueAt no setAside
snoozed and due, or fingerprint changed yes —
kept and valid no kept
kept and invalid yes —

Hygiene review's Leave pending is per-pass operational state on the server, not a log state. It skips a finding while its fingerprint is unchanged (hygiene next --defer), and the finding still counts in awaitingReview. The review state gains pass (a Start after a complete or idle review begins a new pass and clears the list), deferred: [{ findingId, fingerprint }], a kept counter, counts.kept, and media: { awaitingReview, setAside, kept } from the last selection. A stored row without them reads as 0 or []. Hygiene effects gain operation defer, and outcomes gain status deferred.

Media review effects (additive, #2781)

A media finding's four governed effects are Delete, Move, Persist here and Keep a permanent copy. The CLI plans and writes them (brain hygiene media, cli.md); the UI server owns capture, compensation, Undo and recovery, in the bounded change journal (Rollback A).

  • Capture before mutation. Under the exclusive writer gate, a user journal turn covers every file the plan lists, and each pre-image (the media bytes for Delete and Move, each rewritten note, the config) is retained and verified by SHA-256 before the CLI is asked to write. A capture the journal's 10 GiB shared cap, or its per-file copy bound, cannot hold refuses as capture-capacity, with nothing written.
  • Per-file compensation. A file is put back only while it still holds exactly what the effect wrote. Anything else is conflict (or left-changed for a folder no longer empty) and is left alone; the outcome is then partial. No all-file atomicity is claimed.
  • Undo first checks that every retained pre-image still exists and every file still holds its post-image, and refuses before any write otherwise (rollback-evicted, changed-since with the path). It then applies the same per-file rule. Undo is not guaranteed for any period: the journal may evict rollback data under pressure.
  • Recovery after a restart compensates an interrupted apply, or finishes an interrupted Undo, from the durable operation row. It never re-applies.
  • Keep a permanent copy records its kept entry with brain hygiene keep-copy only after the copy verified (step 6b). A refused entry compensates: the copy is removed and the config restored. The original's bytes, path and pins are never touched.
  • Undo of Keep a permanent copy removes its kept entry in the same Undo, after the copy and config are restored, with brain hygiene unkeep <id> --journal <journalId>. The original is pending again, without an invalidated: … copy-missing receipt. An entry already gone needs nothing; any other answer makes the Undo partial with reason kept-entry-unremoved (or kept-entry-unknown). A compensated or recovered copy removes an entry its lost step 6b receipt may have written the same way.

Additive shapes in @schlessera/brain-ui-sdk:

Surface Change
HygieneEffect.operation gains media-delete, media-move, media-persist, media-keep-copy; the effect may carry destination and premiseDigest
InboxOption.media HygieneMediaPlan: the CLI plan (action, premiseDigest, destination, files, captureBytes, links, references, rewrites, blockers, configDiff, copy, resultClass, refusals)
HygieneAction.outcome.status gains kept, a confirmed Keep a permanent copy
HygieneReviewMediaUndoCommand, HygieneReviewRequest new (0.45.0): { operation: "undo-media", journalId }, Undo of a confirmed media effect from review (below); HygieneReviewRequest is it or the unchanged HygieneReviewCommand, parsed by the new hygieneReviewMediaUndoCommandSchema and hygieneReviewRequestSchema; hygieneReviewCommandSchema is unchanged
HygieneReviewRead.mediaUndo HygieneMediaReceipt, on the undo-media response only (0.45.0)
HygieneAction.outcome.media HygieneMediaReceipt: { journalId, action, outcome: "pending"|"applied"|"kept"|"compensated"|"partial"|"refused"|"stale"|"undone", reason?, refusals?, files: [{ path, state: "created"|"changed"|"removed"|"restored"|"unchanged"|"left-changed"|"conflict" }], copySha256?, undo: { available, reason?, path? } }

A media finding's card offers Persist here, Move to a final location…, Keep a permanent copy… (scratch only) and Delete…, before Leave pending. Persist and Delete are planned when the card is prepared; Move and Keep a copy when a destination is previewed (POST /hygiene/review/preview with the path as input). A plan with refusals keeps them on the option and has no premiseDigest, so it cannot be confirmed. Confirming applies exactly the previewed plan: an applied effect resolves the card as fixed, a kept copy as kept (the review's kept counter); stale and refused leave it pending; compensated and partial are check_failed with that code. An old server that cannot parse kept fails closed as check_failed. Recovery confirms a kept copy only when the log's entry is kept with the same fingerprint and copy SHA-256; otherwise receipt-unknown. Routes and their error strings are unchanged.

Undo from review (additive in 0.45.0, maintainer ruling 2026-11-03). POST /api/hygiene/review with strict { "operation": "undo-media", "journalId": "<HygieneMediaReceipt.journalId>" } undoes the confirmed effect of a resolved media card, for the principal that applied it, under the same preflights as the CLI Undo: every retained pre-image still held and no file changed since. The response is the review read plus mediaUndo, the Undo's own receipt. A refusal is a 200 result, mediaUndo.outcome: "refused" with reason (changed-since with path, rollback-evicted, already-undone), and nothing is written. The resolved card keeps its status and gets the Undo's receipt by inbox replication: outcome.status: "undone" with media the Undo receipt; check_failed with code partial when some file could not be put back; or, after a refusal, its original outcome with media.undo re-read. An undone effect leaves the review's fixed or kept counter. The finding is pending again and an active review selects it in order, on a new card. Repeating the command returns the same receipt and changes nothing. A journalId that names no resolved media card of this principal is review_failed (500); malformed is 400, as for every command.

Module hygiene context (breaking, #699)

A module hygiene check receives HygieneContext<C> = { queries, root, config }. The raw db content-index connection is removed (pre-1.0 break, ruled in #696). queries: ContentIndexQueries is a frozen object core creates per audit, bound to the brain root resolved once at creation. Its nine methods are those of the content-index query API with the same options minus brainPath, and the same validation, defaults, caps, ordering and QueryResult envelopes. A brainPath supplied anyway (including through a getter), an argument to readGraphMeta or a later change to ctx.root cannot redirect a query. Each call opens and closes its own read snapshot; no handle, SQL, statement or transaction escapes. ContentIndexQueries is a type exported from @schlessera/brain and @schlessera/brain/queries; core does not export a constructor. root and parsed config: C are unchanged, and checks may still return synchronously or asynchronously.

A failed query returns ok: false with its QueryCode; a check should throw on it, which the audit reports as the existing single module-hygiene warning and failedChecks entry rather than a clean result. The jobs jobs-stage check does so with the message opportunity stages could not read the content index (<code>): <action>, and its selection is unchanged: every non-archived, non-null-status opportunity whose path is exactly status.md or ends in /status.md with ASCII case folding, in path order and uncapped. Migration: replace each ctx.db SQL read with the query returning the same rows and keep any path matching in module code (modules.md). The direct-SQL guarantees for other readers are unchanged.

Module dormancy (additive, #527)

Each configured module entry gains optional core-owned enabled: boolean. Omission means active. Core validates the flag and removes it before validating the module's domain block; dormant domain config is still validated and retained. LoadedModule.state is supplied by the loader as "active" | "dormant"; the optional authoring property preserves constructed legacy contexts, where omission means active. CLI, scheduler metadata, skill discovery, classifier hints and module hygiene all read that same loaded state. Dormant types, directory anchors, exclusions and content rules remain registered.

ModuleContribution.instructions?: { text: string } is strictly validated authoritative context returned by setup(validatedConfig). Text is nonempty and contains no ownership markers. Modules declaring instructions have a lowercase name matching ^[a-z][a-z0-9-]{0,30}$; existing manifests without the field retain their naming behavior. The optional manifest canBeDormant?: boolean defaults to permitted; false makes CLI disable refuse with dormancyReason?: string or a default explanation. A declared reason is nonempty. Existing module authoring calls and required fields remain unchanged.

Toggles preflight source edits and all instruction owners before writes. Legacy mixed generated regions require explicit migration, preserving personal prose outside module-owned spans. No paragraph inference, whole mixed-section deletion or saved-prose restoration occurs. The source-preserving writer changes only a literal entry's flag; an ambiguous executable target is refused rather than serializing its evaluated config. The same-state command can synchronize context after an explicitly reviewed manual flag edit. Validation/ownership failures leave config, managed links and instruction bytes unchanged. Filesystem or emitter failures are reported as failures and require retry; success is not reported for an incomplete context sync. See the format and migration guide.

The list estimate is the sum of characters/4 token estimates of the module's discoverable model-invocable skill descriptions under existing precedence and its contributed instruction text. It estimates hypothetical active context even when dormant, without attributing shared contracts, personal prose or full skill bodies. It counts the authoritative contribution once, rather than summing copies in different agent entry files. The existing enabled/available envelope and declared fields remain; dormant cron metadata is empty. Namespace execution exits 1 with module <name> is dormant — brain module enable <name> without importing or executing the module command. Running sessions keep their loaded state; MCP startup registration reads that same loaded state and skips dormant modules before importing tool definitions. Dormancy is context control, not permission revocation.

Module settings (additive, #528)

ModuleManifest.settings?: ModuleSettings<C> describes a generic editor over configSchema: data-only fields, choices, nested records, ordered lists and record variants, plus optional module CLI actions, computed notes and a pure migration planner. Descriptions are validated at module load. The leaf kinds are text, number, toggle, choice, multichoice, tags and weights; record, list and variant compose them. Unlisted representable schema fields appear through the generic renderer. Unsupported fields show their complete JSON value read-only; unchanged JSON subtrees retain their bytes. Modules do not contribute React settings implementations. Content renderers use the separately declared, build-time-bundled renderer seam. A module using settings has a lowercase name matching ^[a-z][a-z0-9-]{0,30}$.

Core reads settings/<module-name>.json, a JSON object without the reserved enabled key. Own object keys merge recursively over the domain block in brain config; arrays, scalars and null replace. The original configSchema then parses the combined input and supplies defaults. Both active and dormant modules use this path, including ordinary config checks and brain validate. Invalid hand-edited settings fail validation rather than silently falling back. Config reads expose effective validated module blocks. JSON never replaces TypeScript logic or rewrites its source. Settings files and their parent may not alias another path through symlinks; a symlinked brain root remains valid.

brain module settings <name> --json returns:

{ module, key, state, canBeDormant, dormancyReason,
  schema, values, inherited, overrides, provenance, inheritedProvenance,
  revision, notes, ui: { fields, actions, migration } }

schema is generated from the declaration's Zod input schema, with unrepresentable leaves shown read-only. values are effective validated settings; overrides are the unnormalized JSON source object. inherited shows values without that JSON, and provenance distinguishes default, brain-config, saved and migrated. revision is an opaque quoted hash of the loaded config source and settings bytes. Do not derive meaning from it.

--set dotted.key=value parses a JSON value when possible, otherwise a string; numeric path components address existing arrays. --stdin accepts the complete overrides object. Both use the same validated writer as the UI. --revision REV requires a matching revision; CLI omission uses a freshly read revision. --preview with --set or --stdin validates a draft and computes module notes without writing. --action <id> invokes the declared command in its owning module's namespace, using saved config and the existing dormancy guard; it cannot be combined with a settings save.

Successful saves add changed: boolean and commit: string | null to the snapshot. A changed save atomically replaces only the module JSON file and creates exactly one git commit. Structurally unchanged requests preserve all file bytes and produce no commit. The transaction lock serializes revision checks, writes and commit. Stale revisions or a busy transaction return 409; validation returns 422 with { error, status, errors: [{ path, message }] } and writes nothing. Unavailable modules return 404. A write/commit failure restores prior files and target index entries, preserving unrelated staged work. Pre-staged target changes are refused. These errors exit CLI 1.

--migrate --preview runs the module's pure planner and returns its source preview, values, paths and revision. --migrate --revision REV applies that reviewed plan through the same validator and transaction. Changed settings and content are committed together once; stale input, validation, write or commit failure changes neither source. Jobs moves known scoring keys from criteria frontmatter into settings/jobs.json.scoring, retaining raw keyword casing, flat/tiered forms, coercible numbers, odd match values and absent optionals. Unknown scoring keys and unrelated frontmatter/prose stay in the document. It proves identical parseScoringConfig outputs before writing. Legacy frontmatter remains a read fallback until explicitly migrated.

module list --json adds settings, canBeDormant and dormancyReason to loaded entries in its existing enabled array. A malformed module appears as an unavailable row with error, while valid neighbors remain readable and editable; actions still refuse globally invalid config. The Modules editor's HTTP routes are listed as internal transport in http-api.md. GET settings supplies ETag; PUT requires If-Match (428 when absent), passes through field errors and 409 conflicts, and uses the CLI writer. Every route uses the existing authenticated-principal and origin guards. Migration, dormancy and actions are separate confirmed requests. Domain saves never change the core-owned enabled flag, migrate instruction ownership, hot unload a module or cancel running sessions.

Guarantees consumers may rely on

  • Containment. Everything the CLI writes stays inside the brain root. Config-supplied directories, module config paths, and caller-supplied document paths are repo-relative (no absolute, ~, .., or control characters) and resolved through a symlink-aware canonicalizer, so neither a symlinked directory nor a dangling symlink redirects a write out of the repo. A CLI path argument may also be absolute: one inside the brain root is converted to a repo-relative path first, and one outside it is refused (0.45.0, #3422).
  • Mutating commands require an initialized brain. add, import, index, archive, accept-mtime, process, maintain, sync, skills sync, and setup, plus okf export, exit 1 when no brain.config is found rather than initializing a stray directory. Read-only commands (including skills lint) still run.
  • OKF exports are derived and isolated. okf export only writes inside a taxonomy-excluded directory, wipes that derived directory before generation, and never modifies source concept files. okf check is read-only and accepts third-party bundle directories.
  • module list --json cron entries are shape-constrained. A container entrypoint materializes them into a crontab with root privileges, so name is kebab-case, schedule is a 5-field expression, and command is a plain brain … argument string — no newlines or shell metacharacters can appear. A consumer should still re-validate before interpolating, including the module KEY: defense in depth is the contract here, not producer trust.
  • modules config keys must be an npm package specifier or a contained ./path, and are canonicalized before import() — a symlink cannot make the loader execute code from outside the root.

Recommended consumer hygiene

Keep one contract test that runs brain search "x" --json and brain briefing against a fixture brain and asserts the envelope shapes above — cheap insurance against silent breaks.

Configuration exclusions

exclude.files contains exact relative file paths; exclude.dirs excludes directories and their descendants, and exclude.segments excludes matching directory segments at any depth. Directory pruning and output-directory authorization consult only directory/segment rules, never exact-file rules.

Breaking in the next minor release (approved in #224): configuration loading rejects exclude.files entries ending in /, which were previously accepted and could make stats disagree with indexing. The error identifies the field and value and directs the author to exclude.dirs. To exclude a whole directory, replace files: ["drafts/"] with dirs: ["drafts"]. Valid exact-file, directory and segment exclusions keep their semantics; CLI JSON shapes are unchanged.

Backend confirmation-pattern initialization

Pre-1.0 breaking tightening (#251): compileConfirmPatterns and both first-party backend constructors reject a nonempty confirmBashPatterns list if no regex compiles, before turn or tool activity. The server's BRAIN_UI_CONFIRM_BASH regex list follows the same initialization rule. The error names both configuration fields, preserves invalid-source diagnostics, and directs the caller to repair the list or intentionally use []; exact error prose is not a machine schema.

Missing backend configuration still uses the shipped defaults, and explicit [] still disables confirmation. Mixed lists report and skip invalid entries, preserving the valid patterns, their matching order and effects. The server's existing malformed-JSON and structural-entry fallback retains its semantics. The rationale is recorded in confirm-patterns.md.

Nullable asset enrichment

Approved pre-1.0 breaking API change (#411): Enrichment.describeAsset from @schlessera/brain now returns Promise<string | null> rather than Promise<string>. createEnrichment returns null when its completion provider lacks vision (without calling complete) or returns empty/whitespace text for an image or PDF. Non-empty output is trimmed and accepted even if it equals the title. Errors still reject; CompletionProvider.complete and Enrichment.generateChunkContext retain their string return types.

Callers handle null before persisting, caching or embedding a description. Custom implementations return null for no description and non-empty strings for success; always-successful implementations may retain Promise<string>. The shipped @schlessera/brain/testing completion contract suite now asserts null and zero completion calls for no vision, and null for empty output. Caller migration guidance includes an example.

The indexer leaves undescribed assets on their document/chunk/FTS placeholders, keeps them findable by title, and queues neither description cache entries nor embeddings for them. A later --embeddings run retries unchanged assets. A null report says only that no description was returned, without attributing a cause. Existing cached strings are preserved, including title-equal strings; this change prevents new fallback entries and does not identify old ones. Use --forget-cache <path> then --embeddings to regenerate a known bad entry; forgetting resets sidecar and database state for all assets sharing its bytes. Rebuilding only brain.db can reuse the old sidecar and is insufficient.

Backend-authoring permission toolkit

The deliberate toolkit is exported from @schlessera/brain-ui-sdk/server:

Operation Public signature types / result
decideToolPermission ToolPermissionDecisionInput → `ToolPermissionApproval
createToolPermissionRequest CreateToolPermissionRequestInput → PermissionRequest
requestToolPermission `Pick<BackendBridge, "requestPermission"
checkEditedApproval EditedApprovalCheckInput → refusal message `string
compileConfirmPatterns readonly ConfirmPatternSource[], invalid-source callback → CompiledConfirmPattern[]

These operations, their exported types and every signature-reachable bridge, activity and protocol type are intentional authoring surface. They remain experimental until 1.0, then stabilize under the existing versioning policy; this does not schedule or freeze 1.0 early. The API report records the complete signatures and reachable types. The authoring guide shows the binding, and the decision inventory records the exact public/internal classifications under #343 Q1/Q2.

Adapters decide before dispatch, honor denial and use the same snapshotted updatedInput for checking and application. Missing bridges and no-grant surfaces deny; no-grant surfaces never ask the host. A throwing denial-activity reporter cannot change the decision. Bridge rejections remain rejections for the adapter to translate. Edits introducing a per-use confirmation absent from the original input are rejected, including a changed full shell command or archive target; an own __proto__ key is rejected. Allowed safe edits and unchanged confirmed inputs may execute.

Confirmation sources accept strings and { pattern, effect }, compiled case-insensitively in source order. Mixed lists report invalid entries and retain valid ones; a nonempty all-invalid list throws. [] is the intentional opt-out. Explicit tool-name matching, tool grants versus command confirmations, archive checks and enforced-turn posture remain promised behavior.

Bundled defaults can change with documented user-visible release notes while preserving those guarantees. Their precise entries and wording are not frozen; policy formats and behavioral guarantees are not exempt from versioning. Subscription-auth action values, mapping and actionable instruction helpers remain public under the existing wire contract; instruction wording may improve.

Pre-1.0 import migration: the SDK's DEFAULT_CONFIRM_BASH_PATTERNS, ARCHIVING_UPDATE_REASON, archivesDocument, bashCommand, SubprocessEnvAudience, SUBPROCESS_ENV, filterSubprocessEnv and parseSubprocessEnvExtra move from /server to /internal. Claude's DEFAULT_CONFIRM_BASH_PATTERNS alias and VOICE_ALLOWED_TOOLS, and pi's DEFAULT_PI_ALLOWED_TOOLS and TOOL_RISK, move from their ordinary entries to those packages' /internal entries. These explicit internal paths are for first-party implementation sharing at the same lockstep version, with no compatibility guarantee. Their existence does not define another extension seam or classify the other exports covered by the package-wide inventory. Operator environment configuration retains its documented meaning; the subprocess implementation table is internal.

Shared geo library (additive, #525)

@schlessera/brain-geo is a concrete leaf library. Its root export supplies RoutePoint (lat, lon, nullable elevation_m and ISO time), strict parseGpx, routePoint, great-circle distanceM, travel-compatible routeMetrics, trimRoute, quantizeRoute and writeGpx. Existing travel commands retain their JSON types, strict rejection, rounding and serialization. The travel metric contract continues to apply; metrics/writing require nonempty valid geometry.

parseTrackGpx(source) adds recovered file geometry. It returns segments, warnings, kind (track or route), status (ok, partial or no_line), partial, counts (input, retained, omitted, segments) and omissions. Each omission names its zero-based selected-source point index, primary reason and all reasons: latitude/longitude missing-or-invalid or out-of-range. Counts cover the selected track/route geometry, not foreign metadata or waypoints. Every omitted point splits the geometry. Valid isolated points stay in the returned sections as evidence; no_line means no section has two points. Track sections take precedence when usable, otherwise usable routes do. With neither, track evidence takes precedence over route evidence when present. Malformed/unsafe/over-limit inputs throw rather than becoming partial results.

Both parsers use the same guarded XML reader. Recovery never modifies the source, bridges gaps, changes optional unknown values or proves recording/travel from file metadata. Shared ownership and recovery explains why the new policy is separate from travel's strict compatibility entry point.

summarizeTrack(parsed, source) returns the original counts/status, copied geometry, file source, [west,south,east,north] bounds, start/end, shape, warnings, unknown field/reason records and method metadata. Its measurements contain distance, ascent, descent, altitudeMin, altitudeMax, elapsed and movingTime, each with { value: number | null, unit: "m" | "s", scope: "usable_sections" }. Distance uses unsimplified great-circle edges on a 6,371,008.8 m sphere. Sections with fewer than two points remain evidence but do not contribute measurements. Known zero remains zero. Elevation requires complete eligible section samples; ascent/descent share three-point-median smoothing and 3 m hysteresis. Elapsed sums each section's ordered, complete first/last timestamp interval, retaining pauses and excluding gaps; absent/invalid/decreasing timestamps remain unknown. Moving time is always unknown with estimator_not_in_scope. No-line geometry has unknown distance rather than a successful zero. Absent bounds/start/end and unknown shape carry reasons as well. An optional recording claim retains its text and verified: false; the library never infers recording from timestamps.

normalizeTrack(sections, kind?) accepts arrays of normalized point objects with numeric lat/lon, optional elevation_m/time and a default "track" kind. It returns the same ParsedTrack recovery/count/omission contract as GPX. Malformed structure rejects; invalid coordinates omit/split, never coerce or clamp. At most 200,000 points (including omissions) and 200,000 sections are accepted. Missing/invalid metadata is unknown; caller input remains unchanged. EARTH_RADIUS_M is the 6,371,008.8 m sphere used by all distance methods.

nearestTrackPoint(track, query, toleranceM) returns status/partial/counts, distance: {value: number | null, unit: "m"}, closest point: {lat,lon} | null, location: {section,index,fraction} | null, nullable withinTolerance, unknown reasons and method metadata with "great_circle_segment", radius, tolerance and "retained_geometry" scope. Minor arcs include interiors; gaps remain absent. Retained singletons and repeated points are valid spatial evidence. Empty geometry and ambiguous antipodal edges yield unknown; invalid queries/tolerances reject.

trackCoverage(A, B, {toleranceM, sampleSpacingM?}) returns status/partial, counts for both inputs, direction "A_relative_to_B", nullable ratio/covered metres, usable metres of A, nullable {minimumRatio,maximumRatio} bounds, unknown reasons and method parameters. Its "arc_length_midpoints" estimate measures only A's usable sections within tolerance of B's usable sections. Bounds use distance's 1-Lipschitz property to expose uncertainty. The default spacing is max(0.1,min(5,toleranceM/4)) metres; explicit spacing is >0 and ≤1,000 m. Tolerances are finite, nonnegative and at most Earth's half-circumference. 100,000 samples / 5,000,000 comparisons bound analysis. Zero usable length in A, no usable line in B, ambiguous antipodal geometry and analysis-limit exhaustion return null ratio/covered length/bounds with reasons. Partial inputs remain partial; no gaps are filled and no denominator is invented. All helpers require validated ParsedTrack inputs from the shared parser/normalizer.

Shared geo configuration and server geocoding

The root geoConfigSchema, GeoConfig and GeoConfigInput describe concrete configuration. All services are disabled/unconfigured by default; userAgent defaults empty. Endpoints must be credential-free HTTP(S) URLs without query or fragment. Geocoding has enabled, optional url and publicServiceEligible; the public Nominatim endpoint requires the latter explicit responsibility flag. Routing configuration holds per-mode prepared datasets and an off-by-default eligible demo flag; Overpass holds an enabled flag and at most three endpoints. Optional cacheDir, cacheTtlMs (0–30 days, default one day), timeoutMs and admissionWaitMs (100 ms–60 s, defaults 5 s) and minimumIntervalMs (0–60 s, default 1 s) configure the shared concrete clients. Public operator floors cannot be lowered by configuration. No provider registry is added.

@schlessera/brain-geo/server exports GeoClient, result/candidate/attribution, error/source and runtime-option types. new GeoClient(config?, runtime?) validates configuration. Runtime options accept fetchImpl and shared admissionDir; they default to real fetch and the user's global geo admission directory. The root geometry/configuration entry point imports no server I/O.

geocode(query) and reverse(lat,lon) return GeoResult<GeocodeCandidate[]> with status, nullable value/source/error, warnings, attribution and candidate counts. Success is ok, multiple matches ambiguous, usable mixed replies partial, and genuine empty matches no_match; disabled and failure statuses are distinct. A candidate has display/summary/address, numeric point, nullable bounds and OSM identity, unknown accuracy and its reason. Inputs validate before dispatch. No-match is recognized from an empty search or the exact Nominatim reverse error; an unrelated 404 is never fabricated as no-match.

Source reports service/endpoint, fromCache, nullable fetchedAt/cacheAgeMs, requestSent and transfer: {data,sent}. Cache hits preserve fetch age and indicate no new transfer. Errors distinguish disabled, configuration, ineligible, capability, input, timeout, network, http, admission_denied, admission_timeout, bad_response, response_limit and cache_unavailable, with optional HTTP/retry details. Valid provider data retains attribution and qualified accuracy. No transient failure is stored as no-match. Disk caching/admission is shared across cooperating processes; the geo guide documents local aggregation, bounds and fail-closed orphan recovery.

Shared routing results

GeoClient.route(points, mode) accepts 2–100 ordered numeric points and a RoutingMode (car/foot/bike). Configured entries carry url through the route-service prefix, profile, preparedMode, dataset and verification. The mode must match the declared prepared dataset. Public FOSSGIS entries also require explicit eligible demo configuration and its verified endpoint/profile. Otherwise the client returns a capability/eligibility error without transfer.

RoutingResult retains request: {mode,points} and attempts even with no route. Its nullable CalculatedRoute contains kind: "calculated", unsimplified geometry, all requested/snapped waypoints and snap distances, ordered legs with nullable distance/duration, unit-bearing aggregate distance (m) / duration (s), unknown reasons and provider-calculation method metadata. Missing/invalid estimates remain null and partial; zero remains zero. Geometry/waypoint/leg shape must match the request. Calculated duration does not establish movement or recording.

The served source adds dataset name/prepared mode/profile/verification and fallback: {used,reason,primaryEndpoint}. Cache hits retain these and original fetch age. Every attempt lists endpoint/cache status/request transfer and error; a cached demo result can follow a newly transmitted primary attempt. Cache identity includes prepared dataset/mode metadata. Requested points are snapshotted before asynchronous work and are never overwritten with provider-snapped points.

Status is ok, partial, genuine no_route, disabled or error. NoSegment has error code no_segment; OSRM errors retain serviceCode. Invalid query/input and disabled/capability errors remain distinct from no-route and are never cached as it. The demo is off until explicitly enabled and eligible. A missing primary or one genuine availability failure can use one matching demo attempt; denial, admission/cooldown, invalid/no-segment/no-route and local storage failures cannot. An endpoint never retries itself as its own fallback. No geometry is fabricated.

Shared Overpass POI results

GeoClient.poi(PoiQuery) accepts exactly one of near: {lat,lon} or alongTrack: ParsedTrack, radiusM (1–5,000) and 1–10 AND tag filters. Values are exact strings or true for tag presence, never arbitrary QL/regex. Track queries retain section gaps, including isolated points, and refuse beyond 2,000 points, 100 nonempty sections, 64 KiB UTF-8 QL or a radius-expanded 5-degree extent on either axis/pole crossing. Wide/date-line track envelopes are spatial-budget errors; valid source coordinates are unchanged. No track simplification occurs.

PoiResult extends OverpassResult<PointOfInterest[]> with query parameters/counts, nullable recovered-track metadata (partial, original counts/omissions), response counts, truncated and method/limit metadata. Status is ok, partial, genuine no_match, disabled or error. A POI retains OSM identity, nullable name, point, position: node | bounding_box_center, tags, openingHours: {value,interpreted:false}, representative-point distance (m) to the query point/retained track and unknown reasons. Missing/empty opening-hours text is null/not_mapped; there is no current open/closed interpretation. Selection uses Overpass around geometry. A way/relation center is a representative bounding-box center and may lie outside the radius; its distance is not the distance to its entrance/full geometry. Distance uses the shared sphere/minor arcs, never an invented gap. Empty/undefined track distance stays null with a reason.

Responses contain at most 1,000 usable results. A 1,001st sentinel marks a partial capped answer with one counted omission and warns that more may exist. More than 1,001 elements is response_limit; invalid/duplicate elements are counted omissions, and wholly malformed nonempty responses fail rather than cache as no-match. Opening-hours absence alone does not mean malformed data.

OverpassResult<T> retains all endpoint/cache/transfer/error attempts and source fallback {used,reason,primaryEndpoint}. Ordered endpoint configuration is capped at three, duplicates are attempted once, and a cached fallback remains visible after a primary attempt. Genuine availability failures can fall through; admission, cooldown, quota/resource denials and local storage failures cannot, even to a cached alternate. HTTP 504 has Overpass resource-admission meaning. JSON resource/quota remarks likewise refuse and persist cooldown; incomplete timeout remarks are errors, never genuine empty matches. Attribution accompanies usable OSM replies.

Shared background geometry and SDK adapters

The geo root exports the existing SDK pure coastline helpers/types, without server I/O. SDK server exports retain their names/types and alias the shared implementation. CoastlineConfig additively accepts optional canonical geo: GeoConfigInput and shared runtime admissionDir. Required legacy enabled/url/User-Agent and optional timeout/fetch injection remain supported. fetchCoastline(request, config) retains the exact CoastlineResult keys and result-or-empty/partial failure behavior; enabled:false prevents all requests, including with a canonical config supplied. Legacy settings adapt to concrete shared cache/admission without wire changes.

GeoClient.coastline(CoastlineRequest) returns CoastlineServiceResult, with the existing geometry value and per-layer queries: {layer,result}[]. Layers are CoastlineLayer (coastline, roads, streets); each result is OverpassResult<CoastlineLayerGeometry> (lines, input/omitted counts). Its source is the latest successful layer, or latest attempted layer if none succeeded; the complete query list is authoritative for mixed sources/cache ages/transfers/errors. Bounds are finite in-range ordered [west,south,east,north], capped at 5 degrees per axis; width is >0 and ≤16,384. Existing detail/tolerance/geometry algorithms are preserved. Service geometry is bounded to 10,000 ways/200,000 vertices, including malformed entries, and shared body/time budgets. Mixed malformed data or failed layers with usable geometry are partial; no usable data after failure has null value/error. Genuine empty successful geometry has no_match and empty value. Refusal/configuration/local storage failures stop later layers. Attribution and every layer's endpoint/fallback/attempt evidence remain available.

reverseGeocode(coords, ReverseGeocodeConfig) retains exactly {displayName,summary,address} | null. Required legacy enabled/url/User-Agent settings remain; additive optional publicServiceEligible, canonical geo, fetchImpl and shared admissionDir select the concrete client/runtime. Canonical service/cache configuration wins; legacy enabled:false still prevents requests. Public Nominatim requires explicit informed eligibility, which is never inferred from enabled and grants no permission beyond the public-service policy. An ineligible public request is an existing nullable failure path, preserving raw coordinates in the location tool. First-party backends default NOMINATIM_PUBLIC_SERVICE_ELIGIBLE to false; only recognized truthy tokens opt in. Configured nonpublic endpoints remain available. Responses are validated and cached by endpoint/exact coordinates; transient failures are not cached as null. No MCP/tool/result/wire shape or protocol revision changes.

Canonical consumer configuration

brain.config.ts/JSON optionally accepts geo with the shared GeoConfigInput shape and defaults. Root omission remains omission: parsing {} produces {}, and new services remain off. BrainConfig is the authoring input type so the new nested defaults do not require callers to write every optional field. defineConfig remains a typed identity function; load-time schema validation still validates the whole configuration. Core's optional geo response-cache directory uses repoRelativePathSchema and resolves inside the brain through safeResolve, including symlink containment. repoRelativePathSchema is exported from @schlessera/brain for a module author's configSchema: a setting that names a directory inside the brain (finance's clientsDir, images' imagesDir) validates with it, which refuses absolute, ~, backslash and .. paths before the module resolves the value under the brain root.

SDK server additively exports GeoConfig, GeoConfigInput and geoConfigSchema from the concrete geo library. UI server CoastlineConfig.geo is optional for existing explicit configurations; BRAIN_GEO_CONFIG_JSON supplies it from the environment, validated at startup. Relative response-cache paths resolve from BRAIN_PATH; invalid/empty/malformed configuration fails rather than silently using legacy public endpoints. Canonical service settings take precedence over legacy Overpass settings. Legacy disabled prevents requests. Existing permanent geometry cache keys/results and 5-degree route bounds remain unchanged. The server environment setting is not forwarded to child processes by default.

Local vector static maps

Geo server additively exports staticMap(input, client) and GeoClient.staticMap, plus StaticMapInput, StaticMapTrack, StaticMapPin, StaticMapResult, StaticMapReason, StaticMapScale and the track/pin/leg evidence types. Input has optional tracks: {track:ParsedTrack,source:TrackSource,label?}[], pins: {lat,lon,label}[], already-calculated routes: RoutingResult[], bbox: [west,south,east,north], title, widthPx and background: "auto" | "none" | CoastlineServiceResult. Omitted background is auto. No routing request is made by a map call. A concrete prefetched background sends no background request; "none" also sends none.

The result has status: ok | partial | no_map, kind: geometry | track_only | none, nullable reason, nullable png: Uint8Array/svg: string, widthPx, nullable heightPx/bounds, complete text, title, copied tracks/pins/legs/routes, nullable background, attribution, warnings and method. Reasons are unsupported_projection, background_extent, background_unavailable, background_omitted, no_spatial_input, unsupported_text, render_budget and renderer_unavailable. Every no-map outcome has null images and preserves complete text/source/summary evidence. Invalid or oversized input throws.

Track evidence retains one-based index/label, full unsimplified TrackSummary, exact original omission indices/reasons, and visible/cropped retained-vertex counts. Pins retain original coordinates/label, one-based index, drawn and nullable reason outside_viewport | no_artifact. Every requested adjacent route pair has a leg with route/index/mode/from/to, available and nullable distanceM/durationS; genuine zero remains zero. Route stops not represented by explicit pins gain numbered pins. Missing route geometry never creates a line, and imported section gaps remain gaps. All stops/legs remain in the full legend, including cropped stops.

The method records spherical_web_mercator, source_vertices_in_mercator, the 85.0511287798066-degree latitude limit, gaps: preserved, bundled IBM Plex glyph outlines and nullable scale {value,unit:m,lengthPx,latitude, method:mercator_at_center_latitude}. Unsupported polar/date-line/wrapping extents preserve source coordinates rather than clamp them. Projectable bounds wider than 5 degrees per axis skip background querying and produce a plain image. Missing background also produces a qualified plain image. Used OSM geometry/calculated routes retain attribution; FOSSGIS-derived graphics additionally carry the operator's CC BY-SA 2.0 graphics link. No browser, raster tiles, system fonts or external resources participate in rendering.

Width is 320–2,048 integer pixels, default 1,024. Maximum inputs are 100 tracks, 1,000 explicit pins, 100 route results and 200,000 aggregate source-track/route/stop points, including track omissions. Prefetched background is bounded to 10,000 ways/ 200,000 vertices. Labels and provenance text are bounded and reject controls. A complete image/legend above 32 million pixels, 16,384 height, 200,000 text characters, 16 MiB SVG or 64 MiB PNG gives a text fallback. Unsupported glyphs and unavailable local rasterization have distinct reasons. No stop, leg, omission or attribution is silently truncated.

Geo CLI envelopes

brain geo adds the five subcommands below without changing old CLI/MCP/wire/ frontmatter contracts. Types are GeoGeocodeOutput, GeoRouteOutput, GeoPoiOutput, GeoTrackOutput and GeoMapOutput in core's geo command. Each machine invocation emits one JSON document; usage exceptions remain stderr-only.

Command JSON fields
geo geocode <query> Complete GeoResult<GeocodeCandidate[]> plus operation: geocode, request: {query}.
geo geocode --reverse lat,lon Complete same result plus operation: reverse, request: {lat,lon}.
geo route lat,lon... --mode car|foot|bike Complete RoutingResult plus operation: route.
geo poi --near lat,lon | --along file.gpx --radius-m metres --tag key[=value]... Complete PoiResult plus operation: poi, nullable brain-relative sourceFile.
geo track file.gpx Complete TrackSummary plus operation: track, exact omissions, nullable nearest: NearestTrackPoint and nullable comparison: {summary:TrackSummary,omissions,coverage:TrackCoverage}.
geo map [file.gpx...] --out file.png Complete StaticMapResult except png/svg, plus operation: map, nullable artifact: {path,format:png,bytes}.

Coordinates are latitude,longitude in CLI arguments. Bbox order remains west, south,east,north. Route mode is required and ordered stops remain intact. POI requires exactly one spatial form, explicit radius and 1–10 exact AND filters; repeatable bare keys mean presence and duplicate keys refuse. Track nearest/ comparison requires explicit --tolerance-m; optional --sample-spacing-m needs comparison. Counts, partial status, model, tolerance, direction and unknown reasons remain the shared results; originals are never rewritten.

Map accepts repeatable --pin lat,lon,label (commas after the first two belong to the label), optional repeated routing --point lat,lon with required --mode, --bbox, --title, --width and --no-background. Routing resolves before the static map operation; this is explicit requested traffic rather than a hidden route calculation in rasterization. Output is brain-relative PNG, requires an initialized brain and explicit --out, and replaces a chosen regular file. Containment checks include symlinks and repeat after asynchronous resolution; scratch uses its existing genuine/ignored-directory write and pruning rules. No-map returns null artifact and does not create its destination. Complete text/ provenance/omissions remain in the JSON while binary PNG/SVG contents are omitted. Aggregate map input counts omitted source points as well as retained geometry.

Human mode prints source, fetch/cache age, all transfer attempts, dataset/fallback, unknown estimates and applicable attribution. Exit 0 covers valid/partial results, genuine no-match/no-route and documented map/text fallback; input/usage is 1 and a failed service command/storage/local renderer is 2. Typed service input errors may emit their one JSON result with exit 1; parse/path/usage exceptions print only to stderr. brain render retains its existing envelope/network-denied behavior: callers inline the already-created local PNG as data before invoking export.

Imported track files in chat (additive)

ClientChatMessage.files?: { kind: "file"; path: string }[] references validated originals in the existing share staging directory. The host resolves and reparses those originals before first, queued, native follow-up and retry dispatch. A client cannot supply trusted coordinates, measurements or a replacement source path. Images remain in attachments; at most SHARE_MAX_FILES (10) files and images, with SHARE_MAX_TOTAL_BYTES (50,000,000) total decoded/original bytes, may accompany one message. Existing image-specific limits still apply. Bad references fail as ATTACHMENT_REJECTED, before a backend runs.

The paired UI transports POST /api/track-upload and GET /api/tracks are internal HTTP routes; their published socket/block behavior remains this additive contract.

POST /api/track-upload uses the existing authenticated, same-origin multipart boundary, concurrency and total-body caps. It admits validated GPX, KML 2.2 and the supported GeoJSON subset only. TRACK_MAX_FILE_BYTES is 20 MiB, matching the shared parser; a larger track returns 413 {error:"file_too_large",limit}. Ordinary JSON, PDF, CSV, unsupported formats, malformed or unsafe structures return 422 {error:"unsupported_track",message}. A title/text without a track cannot bypass admission. A cancelled uncommitted stage is removed. Originals are written unchanged through the existing atomic staging path; no knowledge-base content is created. Existing POST /api/share retains generic file intake and never labels an unvalidated file as a track.

An initial accepted retry receipt can include canonical files for the new local user row; receipt status queries still disclose no original input.

SharedFileMeta additively carries incomingName?, detected?: "gpx"|"kml"|"geojson" and summary?: TrackFileSummary. Validated nameless, extensionless or generic .bin, .dat, .tmp, .xml and .json tracks get a detected format extension in their sanitized, collision-safe staged name. sha256? identifies a validated original; exact retries check the original before accepting and refuse changed or unavailable files. Incoming display name/MIME, detected format and actual staged name/path remain separate bounded, inert metadata. TrackFileSummary is the shared TrackSummary without geometry, plus input waypoint count and omitted waypoint count. Replayed user messages add files?: SharedFileMeta[]; the operational UI database retains this metadata, not authoritative content or original bytes. Only a byte-identical recorded server context is removed from replay text. User-authored lookalikes remain user text.

GET /api/tracks?path=<staged reference> returns TrackFileView: the shared ImportedTrack plus canonical file: SharedFileMeta, recomputed from the original. Unavailable, expired or unsupported originals return 422 {error: "track_unavailable",message}; a missing/overlong path returns 400. Reads enforce containment, regular-file/no-symlink checks and actual byte limits.

Retry for a file-backed request is advertised only after the server checks the original bytes, before consuming eligibility. The session catalog is internal to the server (#1053).

show_block adds {kind:"track",source:{path:string},title?:string}. The source is a staged reference only: no model-authored geometry, viewport, metric or provenance field. The client resolves the original through the route above. The classifier does not infer track blocks from prose. TrackMap is a presentation-only kit component; MapView.fitPoints fits a track envelope without extra pins, and MapPin.marker distinguishes start/end shapes and their S/E merge. Existing place-map behavior stays unchanged.

Imports recover invalid coordinates into separate usable sections with exact counts/reasons. Every omission and original section boundary breaks both drawing and measurement. Valid zero, repeated and polar coordinates remain source coordinates. Optional invalid/missing elevation and timestamps remain unknown; elapsed includes pauses within sections and excludes gaps, with incomplete or non-monotonic required times unknown. Moving time remains unavailable. Elevation uses the shared three-point median/3 m hysteresis and complete eligible altitude input. Values cover usable_sections; recovered values say partial. Formats and timestamps never establish recorded travel: coordinates are file-provided.

parseImportedTrack in @schlessera/brain-geo owns adapters: GPX track/route and waypoints; KML LineString, MultiGeometry and Point, with no NetworkLink, Model or polygon import; GeoJSON LineString, MultiLineString, Point/MultiPoint and their Feature/collection wrappers, with no alternate CRS or polygons. XML is strict, UTF-8/ASCII, entity/DTD-free; input is bounded to 20 MiB, 200,000 total line and waypoint points and 128 nesting levels. The original is never rewritten. Shared measurement input and displayed lines are unsimplified in this cut; all retained points remain drawn unless the entire projection is unsupported.

The static map retains a labeled track-only line when background geography is unavailable or its drawn envelope exceeds either 5-degree query axis. It sends no oversized geography request. A Mercator/padded frame outside the supported latitude/longitude range gives a clear reason with the complete summary, waypoints and original reference, without clamping/wrapping source points. PNG/PDF sharing resolves file and optional geometry before composing static HTML; the scriptless renderer performs no network fetch. An expired original refuses track export rather than drawing an empty frame.

A host's manifest must advertise MIME types together with extensions. Extend its existing generic/image entries with these track entries; use the SDK worker handler at the same action. The generated-host change is tracked separately in brain-hosting-template#10. The example is checked through registerShareTarget and actual multipart parsing.

{
  "share_target": {
    "action": "/share-target",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
      "title": "title",
      "text": "text",
      "url": "url",
      "files": [{
        "name": "files",
        "accept": [
          "application/gpx+xml", ".gpx",
          "application/vnd.google-earth.kml+xml", ".kml",
          "application/geo+json", ".geojson",
          "application/json", ".json"
        ]
      }]
    }
  }
}

A .json or generic MIME/name is identified by validated contents. Ordinary JSON still receives no track summary. See the GPX schema, KML reference and GeoJSON RFC 7946 for source formats; the supported subset and recovery policy above govern this import contract.

Pi native endpoint routing (breaking routing migration, #762)

Explicitly declared built-in pi provider/model profiles are validated against the supported catalog, then resolved from the same configured native ModelRuntime used for inference. New ordinary/autonomous turns honor native models.json endpoint configuration. Ordinary persisted resumes use that runtime without overriding their saved model and still refuse native model fallback. The brain's transcript directory is independent of native agent-directory model/auth/cache discovery. No new profile endpoint field, schema, custom-model discovery or provider seam is introduced.

On every inference request, a native authentication-supplied endpoint wins; otherwise the configured model endpoint is used. A configured proxy is conditional on native authentication not supplying an endpoint. A differing native auth endpoint alone is not a configuration refusal. The backend neither forces native credentials to a configured URL nor duplicates credential discovery/refresh, subscription/account routing or billing ownership. Native refresh may contact authentication servers. Selected provider/model identities and declared picker profiles stay fixed. A resident session retains its runtime; no hot-reload promise for models.json is added.

This newly honored routing replaces previously ignored endpoint configuration for new sessions. Review native model/auth configuration when adopting the change. The 2026-10-03 endpoint policy records the prior maintainer ruling and approved migration: contract/breaking, a CONTRACT: commit and same-commit backend/contract guidance plus a named minor changeset before 1.0 (major from 1.0). No dependency change or release date is selected. Autonomous nonpersistence, exact tool authority, loaded-runtime compatibility and the containment/dispatch gates remain binding; endpoint support does not enable autonomous execution.

Scheduled tasks (additive, #914)

brain schedule add|list|cancel|reconcile|due and the authenticated host routes below store and inspect scheduled tasks. They never run one. No dispatcher is wired: every stored task reports executionAvailable: false with blockedReason: "dispatch_disabled" until the Queue runtime trigger (#915) exists and #689 enables it. Bridge tools (#916) and PWA review (#917) remain prepared below, not shipped. Types, bounds and receipts are the ones in the prepared contract, with the refinements this section lists; where the two differ, this section wins.

Storage. The definition is context/scheduled-tasks/definitions/<taskId>.md under the canonical brain root; cancellation moves it to context/scheduled-tasks/retired/. Core excludes context/scheduled-tasks from indexing by default (configuration), so a forced brain index neither reads nor removes definitions. Frontmatter values are canonical JSON (valid YAML flow syntax) in the fixed order schedule_schema, id, when, scope, limits, notifyOnSuccess; the body is the exact prompt. A file that is not byte-identical to the approved snapshot quarantines the task (definition_drift); the host never rewrites it. The approved snapshot, fingerprint, approval, receipts and occurrences live in the UI operational database and travel with the operational backup. A restore pauses every enabled task as restore_pending and marks each outstanding occurrence unknown; only operator reconciliation (below) reopens them. Publication is refused (unsupported_capability) when Git would ignore the definition path.

Supported tools. The host accepts only brain_read, validated against the core tool's registered input schema; its path must be an approved target. Any other tool, a model alias, or non-empty egress is invalid_request (no supported tool uses the network). Targets must be exact, normalized, non-hidden brain-relative paths outside context/scheduled-tasks, context/policies, brain.config* and brain.db*, with no symlinked component. A variable input may not vary a target path.

Execution policy. The reviewed executionPolicy is the host's default backend ID, its preferred profile ID (or null) and the operator-configured BRAIN_UI_SCHEDULE_INFERENCE_ORIGINS. While that variable is unset, proposals are refused with unsupported_capability. Any change to the policy before approval or publication is definition_conflict; propose again.

HTTP refinements. POST /api/schedules/proposals accepts an optional clientTimeZone beside {key, definition}: the CLI's --client-time-zone. An invalid explicit or client zone is invalid_request; neither falls back. POST /api/schedules takes {proposalId, approvalId?}: the stored approval for that proposal is used when approvalId is omitted, which lets a creator publish after an operator approved elsewhere. Only the proposal's creator can publish it. Approval requires an owner principal (password mode) or the ambient principal (other modes); a delegated agent credential gets unauthorized. Delegated principals see and cancel only the tasks they created; operators see all. The occurrence stop route is not mounted yet. All responses carry Cache-Control: no-store.

CLI. Syntax and envelopes are the prepared ones. add --approve prints the whole materialized envelope, execution policy and fingerprint on stderr and grants only when the operator types approve on an interactive terminal; with no TTY, or any other answer, it exits 1 with approval_required and the stored proposal ID. --attempt-timeout-ms and --max-operations form one reviewed limits object; a missing half takes its default. The scope file is a regular, non-symlink file of at most 8 KiB holding one JSON object. In JSON mode, argument errors are {ok:false,error:{code:"invalid_request",...}}, exit 1; credential_file_invalid exits 1; server_unavailable, unsupported_capability and invalid_response (including any redirect, which is never followed) exit 2. Every success envelope is schema-checked before it is printed.

Operator reconciliation. POST /api/schedules/:id/reconcile takes {key, decision: "reopen"} and answers 200 {ok: true, changed, task}. It is the verified operator decision the record requires before a task paused as restore_pending or unknown_effect may run again: only an operator principal (the same rule as approval) may make it; a delegated agent gets unauthorized, or not_found for a task it cannot see. The host still requires the definition file to match the approved bytes and the host's execution policy to match the approved one (otherwise definition_conflict), and a usable creator (otherwise unauthorized). It refuses while the operational restore marker is pending (unsupported_capability). When a completed restore named a different brain directory, the first reconciliation binds the ledger to it; the root identity and every fingerprint are unchanged. Unknown occurrences keep their outcome and their due instants stay consumed, so nothing is replayed; a one-off with nothing left to run, or a recurrence past its endAt, becomes expired instead of active. Keys are receipts as for cancel: a matched replay, or a task with nothing to reconcile, answers changed: false; a key reused for another task is key_conflict. brain schedule reconcile ID --key KEY shows the task and its last occurrence on stderr and sends the decision only when the operator types reopen on an interactive terminal; otherwise it exits 1 with approval_required. Its JSON is the route's envelope.

Occurrence admission (not wired). The UI server package contains the private admission boundary #915 will drive. It turns a task's latest fresh due instant into one occurrence and its first Queue item in one immediate transaction; concurrent callers in any process admit it once, and instants arriving while work is outstanding are skipped as busy. Admission and every start require the creator and the approving operator to still be usable (otherwise blockedReason: "authority_unusable") and the host's current execution policy to equal the approved one (otherwise "backend_unavailable", which list and due also report). A start refused for one of these reasons makes no model call and ends that occurrence failed, so a later due instant can run once the cause is fixed. At or after an approved endAt, queued or retrying work expires instead of starting. The Queue claim reserves budget as for any autonomous work, and the start transaction is the cancel-versus-start boundary. Each attempt gets the approved attemptTimeoutMs (at most 600000 ms, never past the occurrence's 24-hour expiry), and its claim is released only after the backend unwinds. Retries, yields and continuation items share the occurrence's maxOperations. An attempt that died before its backend acquired the reservation retries; one that died after it is unknown, is never replayed, and pauses its task as unknown_effect. A one-off ends completed, failed or expired with its occurrence. No route, CLI command or core runner reaches this code, and the production app constructs none of it.

Scheduled-task contract preparation (#913)

Selected design; partly implemented. The scheduled-tasks record supplies the six approved policies and this bounded specification for #914–#917. Scheduled tasks (#914) ships the definition store, approval, CLI and HTTP routes except occurrence stop. The bridge tools, occurrence stop, runtime dispatch and Activity/notification correlation below remain unimplemented, and no permission grant or dispatch exists. Consumers must not infer availability from these examples. Implementation adds its actual schemas/exports/routes/receipts, compatibility tests and minor changesets in CONTRACT: commits; #689 still gates production execution. Existing contract shapes above are unchanged.

Shared definition and response types

All new request/nested objects are strict: reject unknown fields, duplicate JSON/YAML keys, nonfinite numbers, invalid UTF-8 and NUL. Decoded control characters are rejected in IDs, paths, origins, names, operation and cursor; textual prompt/tool-input values may contain LF/TAB. IDs and keys match [A-Za-z0-9][A-Za-z0-9._:-]{0,127}; IDs are host-minted and keys are caller-chosen per authenticated principal/root/ operation. Date values are valid offset/Z ISO instants, normalized to UTC Z; responses use integer milliseconds for durations such as attemptTimeoutMs and integer counts for counters, not interchangeable date/string values.

The JSON request definition is exactly:

type ScheduleDefinitionInput = {
  prompt: string;
  when: { kind: "at"; at: string; timeZone?: string }
      | { kind: "cron"; cron: string; timeZone?: string; endAt?: string | null };
  scope: {
    operation: string;
    tools: { name: string; inputs: JsonObject }[];
    targets: string[];
    egress: string[];
    variableInputs: {
      pointer: string;
      bound: { kind: "enum"; values: JsonScalar[] }
          | { kind: "string"; maxBytes: number }
          | { kind: "number"; min: number; max: number };
    }[];
  };
  limits?: { attemptTimeoutMs: number; maxOperations: number };
  notifyOnSuccess?: boolean;
};

JsonScalar is null/boolean/string/finite number; JsonObject is bounded JSON with no prototype/authority fields and recursively validated against the selected registered tool's actual input schema. Max JSON nesting is 8, arrays 32 members, objects 32 own keys. Scope is at most 8 KiB serialized; prompt is nonempty after whitespace validation and at most 16 KiB UTF-8. Preserve its approved text. Operation is nonempty, at most 256 UTF-8 bytes. At most 16 tools, 32 exact brain-relative target paths (256 bytes each), 16 exact normalized tool-egress origins (256 bytes each), and 32 variable leaves are allowed. No command/template interpolation or arbitrary executable schemas. A variable pointer names an existing tool-input leaf; enum contains 1–32 bounded scalars, strings have a positive maxBytes <= 16384, numeric min/max are finite and ordered. It cannot change tool names, target paths, egress, timing, limits, actor or authority. All undeclared leaves retain exact approved values. Reject duplicates and unregistered/unsupported inputs; string bounds do not bypass tool validation.

when is an exclusive union, with the validated/resolved IANA zone persisted on both forms. CLI can supply a validated client zone explicitly; the bridge obtains its fallback zone from verified turn ClientEnvironment, never model text. If neither is usable, disclose UTC. An explicit invalid zone is an error. Optional recurring endAt is a future offset/Z instant at first publication; omission materializes to null. It is an immutable scheduling window, not a live-attempt stop: at now >= endAt invalidate future/unstarted work and stale continuations, report configuration expired, and retain a started attempt under its original authority/deadline/freshness. Due eligibility also requires evaluatedAt < endAt when set. Cron is <= 128 bytes and follows the record's five-field numeric grammar, including Sunday 0/7 and bounded lists/ranges/positive star/range steps. If either day field begins with *, both day predicates must match, including its step; otherwise either may match. Reject extra fields, impossible calendars and unsupported extensions. First creation requires a future offset/Z one-off. A matched recorded retry returns its original receipt even after that date passes.

Missing limits materialize to {attemptTimeoutMs:600000,maxOperations:3}; provided values are positive safe integers with timeout <= 600000 and maxOperations <= 3. Missing notifyOnSuccess materializes to false. These values are reviewed and immutable. They grant neither budget capacity nor execution. Definition files have exactly schedule_schema:1, host id, materialized when, scope, limits, notifyOnSuccess as frontmatter and prompt as the body. Max file size is 32 KiB; the canonical fingerprint covers all materialized fields, exact prompt, host root/creator and the approved host-selected execution policy. The file carries no actor/approval/runtime/control-state authority. Task definition is the retained approved snapshot, not a claim that a drifted raw file is approved; definition_drift/missing-file status quarantines it without silently rewriting it.

executionPolicy is server-owned {backendId:string, profileId:string|null, inferenceOrigins:string[]} (each ID <= 128 bytes, at most 16 normalized origins). Operator review sees it alongside scope; callers cannot select its fields, prices, billing identity or emergency reserve. Runtime/profile changes cannot widen the approved tool/input/target/audience bounds. Current backend availability, conservative billing evidence and daily admission still apply.

type ScheduleDefinition = Omit<ScheduleDefinitionInput, "when" | "limits" | "notifyOnSuccess"> & {
  when: { kind: "at"; at: string; timeZone: string }
      | { kind: "cron"; cron: string; timeZone: string; endAt: string | null };
  limits: { attemptTimeoutMs: number; maxOperations: number };
  notifyOnSuccess: boolean;
};
type OccurrenceState = "queued" | "running" | "unwinding" | "waiting_for_action"
  | "retrying" | "completed" | "failed" | "cancelled" | "expired" | "unknown";
type ScheduleResult = { state: "available" | "pruned" | "unavailable";
  text: string | null }; // UTF-8 text <= 4096 bytes; available implies text != null
// pruned/unavailable require text == null; a reference is not result availability.
type ScheduleOccurrence = { id: string; taskId: string; dueAt: string;
  expiresAt: string; state: OccurrenceState; operationsUsed: number;
  maxOperations: number; runIds: string[]; result: ScheduleResult };
type BlockedReason = "dispatch_disabled" | "budget_disabled" | "capacity"
  | "authority_unusable" | "definition_drift" | "backend_unavailable"
  | "unknown_effect" | "occurrence_limit" | "restore_pending";
type ScheduleTask = { id: string; definition: ScheduleDefinition;
  creatorPrincipalId: string; createdAt: string;
  zoneSource: "explicit" | "client" | "utc_fallback";
  state: "publishing" | "active" | "paused" | "cancelled" | "completed" | "failed" | "expired";
  executionAvailable: boolean; blockedReason: BlockedReason | null;
  nextDueAt: string | null; lastOccurrence: ScheduleOccurrence | null;
  compensationPending: boolean };
type DueCandidate = { taskId: string; occurrenceId: string; dueAt: string;
  expiresAt: string; admittable: boolean; blockedReason: BlockedReason | null };

Returned definition includes all defaults/resolved zone, not optional omissions. Host-owned zoneSource reports the original resolution, including disclosed UTC fallback. A publishing task cannot dispatch. active describes a valid enabled definition, not successful/available execution. paused includes unknown-effect/drift refusal; completed/failed/expired are terminal one-off states; expired also records an ended recurring configuration without inventing a successful occurrence. Recurring occurrence failure does not erase future configuration; unknown effects pause it. Cancelled tombstones remain inspectable. nextDueAt is null when no future occurrence is eligible; lastOccurrence is null before an actual durable occurrence. operationsUsed is an integer 0..maxOperations and runIds contains at most three real attempt IDs; unstarted scheduling facts do not fabricate Activity roots. Only running/unwinding states describe a started attempt. Request keys, task IDs, original-due occurrence IDs, Queue item IDs and run IDs remain distinct.

Due candidates are read-only computed eligibility at evaluatedAt, not claims, persisted occurrences or a promise of execution. The same unconsumed candidate can recur in successive queries. One outstanding occurrence suppresses a new candidate; otherwise choose the latest unconsumed due instant with dueAt <= evaluatedAt < expiresAt (expiresAt = dueAt + 24h). Authority/budget/ backend/restore refusal is visible in admittable/blockedReason. Querying due never advances the scheduler cursor, changes counters or creates model work.

List/due requests accept limit (default 25, integer 1..100) and optional opaque cursor <= 1024 bytes. List supports only state (one Task state) and id (exact task ID); due supports only the paging inputs. Responses are bounded to 512 KiB; a page may stop before its requested limit to respect that byte cap, with a cursor that advances past the last returned row. One entry must fit; never return an unchanged cursor/empty page that cannot progress. Stable ordering is createdAt/id for list, dueAt/taskId for due. Host-signed cursors bind root, authorized actor, operation/filters, initial creation cutoff/evaluatedAt and last ordering tuple, with 15-minute expiry. Invalid/mismatched cursors fail; no client SQL/offset. State/authority may change between pages and must be revalidated for mutations; pagination does not promise a frozen operational snapshot.

Exact response field types are:

type AddScheduleResult = { ok: true; created: boolean; task: ScheduleTask };
type ListSchedulesResult = { ok: true; tasks: ScheduleTask[]; nextCursor: string | null };
type CancelScheduleResult = { ok: true; changed: boolean; task: ScheduleTask;
  runningOccurrences: ScheduleOccurrence[] }; // at most one started occurrence
type DueSchedulesResult = { ok: true; due: DueCandidate[]; evaluatedAt: string;
  nextCursor: string | null };
type StopOccurrenceResult = { ok: true; requested: boolean; occurrence: ScheduleOccurrence };

Proposal success is {ok:true,proposal:ScheduleProposal} (the exact fields/types specified below, including materialized ScheduleDefinition); approval success is {ok:true,approvalId:string}. All success booleans above are literal/boolean as shown; no additional opaque transport wrapper. runningOccurrences contains only actually started running/unwinding work, even if the task is now cancelled.

Approval, endpoints and receipts

Creation has proposal, verified review and publication stages in the same host. A proposal stores no authoritative definition and enables no execution. Proposed supported HTTP endpoints are all mounted behind normal auth/root/principal checks:

Method/path Exact input Exact success
POST /api/schedules/proposals {key, definition} {ok:true,proposal}; 201 new, 200 matched replay.
POST /api/schedules/proposals/:id/approve {fingerprint,decision:"approve"} {ok:true,approvalId}; 200.
POST /api/schedules {proposalId,approvalId} {ok:true,created,task}; 201 new, 200 matched replay.
GET /api/schedules list paging/filter query {ok:true,tasks,nextCursor}; 200.
POST /api/schedules/:id/cancel {key} {ok:true,changed,task,runningOccurrences}; 200.
GET /api/schedules/due due paging query {ok:true,due,evaluatedAt,nextCursor}; 200.
POST /api/schedules/occurrences/:id/stop {key} {ok:true,requested,occurrence}; 200.

proposal is exactly {id,taskId,key,definition,zoneSource,executionPolicy,fingerprint,expiresAt, approvalState}; fingerprint is 64 lowercase hex, expiry is 15 minutes from initial proposal, approvalState is pending/approved. Proposal id and its reserved future taskId are distinct host-minted IDs; neither is execution. The fingerprint uses the materialized stored definition {schedule_schema:1,id:taskId,prompt,when, scope,limits,notifyOnSuccess}, rootIdentity, creatorPrincipalId and executionPolicy. Canonical inputs/key are bound to the authenticated creator/root before approval. Matched retries return the original resolved definition/zone/expiry; changed payload conflicts. Expired unconsumed proposals require a new request/approval, without reviving a retired task.

Only a verified operator decision may create approvalId: normal auth alone, agent credentials, a fingerprint, or an "approved" payload is insufficient. The approve route enforces the existing server-resolved operator authority (owner-kind for principal-cookie mode, the configured operator in ambient modes), checks current proposal/creator, and records actor/channel/time and the immutable fingerprint/nonce. Bridge requests obtain this record through the trusted host permission/Action path, not a model-visible approve tool. CLI explicit review uses its operator credential and confirmed exact fingerprint; a delegated credential can propose/query but cannot self-approve. Stored receipt/provenance, not a claimed input channel or User-Agent, decides eligibility. Unavailable operator provenance refuses approval honestly.

approvalId is opaque (ID bounds above), linked to that exact proposal/root/creator/ operator/fingerprint, expires with an unconsumed proposal and is consumed once by publication. The host rechecks current actor authority and matching definition/ execution policy before publishing, including first-publication future one-off and recurring endAt validation. Concurrent uses return the one original receipt or conflict; no broad reusable grant. No prompt/actor/profile/trust/billing field is accepted on publish/cancel/stop. Replay reads the original publication receipt before consumed-token/past-date checks, still under current authentication. It returns the task's current retained state; never recreates a cancelled file, resets counters or transfers creator authority to a replacement credential.

Cancel keys bind exact task/action within caller/root. Repeated matching cancel returns changed:false and current retained task/runningOccurrences; a changed payload conflicts. Cancellation atomically invalidates future/unstarted work and stale Action continuations, journals retirement and keeps receipts. Started attempts retain their approved snapshot and current authority/expiry limits; an authorized move of unchanged definition bytes is not drift or a running stop. It returns started running/unwinding occurrences honestly, not "agent stopped". Stop keys separately bind an occurrence/action; requested means cancellation requested, not backend drained or prior effects undone. Query until terminal; release only after actual unwind. Pending future cancellation is independent.

CLI and tool mapping

Prepared core syntax (all use explicit --server, optional --credential-file and --json; argument flags are strict):

brain schedule add --key KEY --prompt TEXT (--at ISO|--cron EXPR)
  [--time-zone ZONE] [--client-time-zone ZONE] [--end-at ISO] --scope-file FILE
  [--attempt-timeout-ms N] [--max-operations N] [--notify-success] [--approve]
brain schedule list [--id ID] [--state STATE] [--limit N] [--cursor CURSOR]
brain schedule cancel ID --key KEY
brain schedule due [--limit N] [--cursor CURSOR]

--end-at is accepted only with --cron. scope-file contains the strict scope object, at most 8 KiB, is a regular non-symlink file and grants nothing. CLI add submits a proposal; --approve displays the entire materialized envelope/execution policy/fingerprint and requires explicit operator confirmation before the authenticated approval request. No TTY/affirmative operator confirmation means no automatic grant. A pre-approved matching proposal can publish on a later add retry without --approve; pending proposals return approval_required with the bounded proposal ID in the message, allowing the operator to identify its stored review; no approval secret is exposed. A terminal prompt does not replace the host's operator checks. JSON successful add is {ok:true,created:boolean,task:ScheduleTask}; list/cancel/ due use the exact endpoint envelopes. Creation is not execution.

Credential audience/private-file/no-redirect/10-second request rules follow queue intake, with a 512 KiB response bound for these new schedule responses. No provider key, ambient cookie variable, raw DB, local runner or offline-activation fallback. A store-capable host may accept an approved future schedule while reporting executionAvailable:false; an unavailable store/unsupported approval cannot falsely return created success.

The three future tools are mcp__brain-ui__schedule_task, mcp__brain-ui__list_scheduled_tasks, mcp__brain-ui__cancel_scheduled_task. Both actual backend registrations share strict SDK schemas and eager attached executors. schedule_task input is {key,definition}; the host supplies fallback client zone, proposal/approval identity and verified creator. The model cannot supply approvalId, actor, executionPolicy, profile, trust, billing or wider granted membership. Description: write a self-contained future prompt; propose exact bounded scope; creation needs operator approval and does not execute work. list_scheduled_tasks takes only the list query inputs and is read class; cancel_scheduled_task takes {id,key} and is confirm class, reporting future-only cancellation and actual runningOccurrences. schedule_task is confirm class and not auto-allowed in voice or no-grant/unattended membership. CLI/tool/PWA mutation responses observe the same authoritative records; tool results/errors and model text never become approval. The PWA consumes list/cancel/detail data and the separate authenticated stop where supported, within its reviewed design.

Shared errors are {ok:false,error:{code:string,message:string}}, with bounded message <= 1024 bytes and no credential/prompt/tool/SQL echo. Closed codes: invalid_request (400), invalid_cursor (400), credential_file_invalid (CLI only), unauthorized (401/403), not_found (404), key_conflict (409), approval_required (409), approval_expired (409), definition_conflict (409), server_unavailable (503 or CLI transport), invalid_response (CLI/schema), unsupported_capability (503). No undocumented success-shaped error. Query success, including empty due and inspection of a failed/pruned outcome, exits 0. Validation/auth/conflict/not-found exits 1. Unavailable/unsupported/invalid response/timeout/redirect/5xx exits 2. JSON argument errors use invalid_request/exit 1, not an absent envelope. Retry writes with the original key, never a new key after an ambiguous response.

Complete fictional examples use the pinned Odysseus reference date, 2026-07-12. They are interface examples, not actual CLI/host receipts. The CLI obtains one stored proposal, explicit operator review, then publication through the routes above:

brain schedule add --server https://scheduler.example --credential-file ./operator.json
  --key ithaca-review-01 --prompt "Read notes/ithaca.md and report outstanding checks. Do not change files or use network tools."
  --cron "0 7 * * 1-5" --time-zone Europe/Athens --scope-file ./scope.json --approve --json

The scope file is exactly the scope in this complete add response. JSON mode keeps review/confirmation on the terminal/stderr and emits one result on stdout. The corresponding schedule_task input is {key:"ithaca-review-01",definition} with this complete definition; approval/proposal IDs are obtained by the host, not supplied by the model. Successful creation still reports disabled execution:

{
  "ok": true,
  "created": true,
  "task": {
    "id": "task_ithaca_review",
    "definition": {
      "prompt": "Read notes/ithaca.md and report outstanding checks. Do not change files or use network tools.",
      "when": {
        "kind": "cron",
        "cron": "0 7 * * 1-5",
        "timeZone": "Europe/Athens",
        "endAt": null
      },
      "scope": {
        "operation": "Report outstanding Ithaca checks",
        "tools": [
          {
            "name": "brain_read",
            "inputs": {
              "path": "notes/ithaca.md"
            }
          }
        ],
        "targets": [
          "notes/ithaca.md"
        ],
        "egress": [],
        "variableInputs": []
      },
      "limits": {
        "attemptTimeoutMs": 600000,
        "maxOperations": 3
      },
      "notifyOnSuccess": false
    },
    "creatorPrincipalId": "principal_example_operator",
    "createdAt": "2026-07-12T06:00:00.000Z",
    "state": "active",
    "executionAvailable": false,
    "blockedReason": "dispatch_disabled",
    "nextDueAt": "2026-07-13T04:00:00.000Z",
    "lastOccurrence": null,
    "compensationPending": false,
    "zoneSource": "explicit"
  }
}

A subsequent list reads the same record:

{
  "ok": true,
  "tasks": [
    {
      "id": "task_ithaca_review",
      "definition": {
        "prompt": "Read notes/ithaca.md and report outstanding checks. Do not change files or use network tools.",
        "when": {
          "kind": "cron",
          "cron": "0 7 * * 1-5",
          "timeZone": "Europe/Athens",
          "endAt": null
        },
        "scope": {
          "operation": "Report outstanding Ithaca checks",
          "tools": [
            {
              "name": "brain_read",
              "inputs": {
                "path": "notes/ithaca.md"
              }
            }
          ],
          "targets": [
            "notes/ithaca.md"
          ],
          "egress": [],
          "variableInputs": []
        },
        "limits": {
          "attemptTimeoutMs": 600000,
          "maxOperations": 3
        },
        "notifyOnSuccess": false
      },
      "creatorPrincipalId": "principal_example_operator",
      "createdAt": "2026-07-12T06:00:00.000Z",
      "state": "active",
      "executionAvailable": false,
      "blockedReason": "dispatch_disabled",
      "nextDueAt": "2026-07-13T04:00:00.000Z",
      "lastOccurrence": null,
      "compensationPending": false,
      "zoneSource": "explicit"
    }
  ],
  "nextCursor": null
}

Cancelling before any attempt started retains the task and invalidates future work. Repeating this key returns changed:false and the same retained identity:

{
  "ok": true,
  "changed": true,
  "task": {
    "id": "task_ithaca_review",
    "definition": {
      "prompt": "Read notes/ithaca.md and report outstanding checks. Do not change files or use network tools.",
      "when": {
        "kind": "cron",
        "cron": "0 7 * * 1-5",
        "timeZone": "Europe/Athens",
        "endAt": null
      },
      "scope": {
        "operation": "Report outstanding Ithaca checks",
        "tools": [
          {
            "name": "brain_read",
            "inputs": {
              "path": "notes/ithaca.md"
            }
          }
        ],
        "targets": [
          "notes/ithaca.md"
        ],
        "egress": [],
        "variableInputs": []
      },
      "limits": {
        "attemptTimeoutMs": 600000,
        "maxOperations": 3
      },
      "notifyOnSuccess": false
    },
    "creatorPrincipalId": "principal_example_operator",
    "createdAt": "2026-07-12T06:00:00.000Z",
    "state": "cancelled",
    "executionAvailable": false,
    "blockedReason": null,
    "nextDueAt": null,
    "lastOccurrence": null,
    "compensationPending": false,
    "zoneSource": "explicit"
  },
  "runningOccurrences": []
}

Empty due and explicit approval failure retain their exact envelopes:

{
  "ok": true,
  "due": [],
  "evaluatedAt": "2026-07-12T06:00:00.000Z",
  "nextCursor": null
}
{
  "ok": false,
  "error": {
    "code": "approval_required",
    "message": "Review the stored schedule proposal before creation."
  }
}

Activity, notification and recovery compatibility

The implementation adds optional scheduleId/occurrenceId correlation to actual autonomous Activity roots/rollups and authenticated result links, without changing existing origin or outcome meanings. Original-due identity links up to three attempts/continuations. Pruning must leave an honest result/link state and retained occurrence outcome/counters; no invented run for unstarted/skipped work.

Success completion intents default off per schedule; failure/stuck/Actions keep existing semantics. Deduplicate final occurrence notices across intermediate roots/restarts, preserve generic push payloads and durable authenticated results. Existing 20-intents/hour suppression and three failed attempts/five-minute retry backoff remain. Intent/provider acceptance never claims receipt or reading. Coordinated Git plus operational backup retains all new relations/approval/ compensation/receipt/counter/result/notice state and validates it before gated restore/admission. Existing backup version/schema checks must cover those added relations; old tests/images are not schedule recovery proof. Matching restored bytes alone cannot disprove later cancellation/revocation: require authoritative later receipts or verified operator reconciliation and keep uncertain tasks paused. The 24-hour recovery point and unknown-effect investigation remain binding.

Video watching (opt-in module)

brain video watch <url|path> [--question TEXT] [--start T] [--end T] --json returns { source, engine, model, clip, answer, timestamps, warnings }. source contains kind: "youtube" | "file", nullable url, path, title, and durationSeconds. engine is "gemini", model and answer are strings, clip has nullable numeric start/end in seconds, and timestamps and warnings are string arrays. Unknown metadata is null; model observations are never metadata. Timestamps are deduplicated in answer order, inside the clip; invalid/outside citations produce warnings rather than fabricated evidence. The answer is returned verbatim. Failure emits no success envelope and exits nonzero. Disclosure and diagnostics use stderr, preserving JSON stdout.

The experimental ContentPart union adds a kind: "video" variant with mimeType, optional numeric clip: { start?, end? }, and exactly one source (uri, path or data). CompletionProvider.capabilities.video?: boolean treats omission as unsupported, preserving existing providers; built-in Gemini reports true and Anthropic false. complete adds optional signal: AbortSignal. Video requests do not use configured completion fallback. Uploads created by a request are deleted after success or failure; failed cleanup throws. See the module README for defaults, source restrictions and privacy disclosure. These are additive minor changes.

Bounded hygiene repair operations (additive, #1025)

These operations require valid configuration, except the read-only check configuration-blocker. They use #1024's canonical IDs/fingerprints. Confirmed resolve/Undo and ordinary check require an initialized brain; flags before the subcommand or positional arguments after -- cannot bypass that write guard. Repair previews and the configuration-blocker recheck remain read-only. CLI invocation confirms an effect; server principal authorization and durable Action execution remain the server's responsibility. No inference runs.

Operation Input JSON result / exit
hygiene resolve <id> --handler <name> --input <json> --expect-fingerprint <fp> --dry-run Handler and its typed JSON input status: "preview", id, handler, diff, previewToken; exit 0. No brain file, index or log is written.
hygiene resolve <id> --handler <name> --input <json> --expect-fingerprint <fp> --expect-preview <token> The same input and the preview's opaque premise token status: "fixed", id, changedFiles; exit 0, only after real file validation and detection succeed. stale, refused, check_failed exit 1.
hygiene undo <undoToken> --dry-run Opaque receipt token from a failed post-check status: "preview", id, inverse diff; exit 0, writes nothing.
hygiene undo <undoToken> Confirm that inverse effect status: "undone", id, changedFiles; exit 0. stale or refused exit 1 and writes no content.
hygiene check <id> A logged finding ID status: "still_detected", id, handler, or status: "not_detected", id, changedFiles; exit 0. check_failed or refused exit 1. Still detected and failed checks write no brain file or log.
hygiene check configuration-blocker Reserved configuration blocker identity status: "blocked" (exit 1) or "ready" (exit 0), id, handler; blocked also has reason (loader diagnostic). Both are read-only. Other hygiene commands still refuse invalid configuration.

A diff carries path (brain-relative), before and after (complete exact UTF-8 text), and changes: [{before, after, line}] (every bounded replacement, 1-based line). A handler carries name, category, kind (choice, field, manual, blocker), input: {type, values?, example?}, effect, postCheck, path, line, explanation, and optional suggestedPath. input.type is none, path, string, enum, date or strings.

The fixed handler set is:

  • link-suggested: input null; available only for exactly one contained note matching the target's slug, alias or exact title. No fuzzy match.
  • link-note: input a brain-relative path to an existing, included Markdown document inside the brain. Outside paths, symlink escapes, symlink documents, excluded/non-document/missing targets and unrepresentable link paths refuse.
  • link-text: input null; remove this target's link tokens, keeping each display label (the original target when unlabelled). Code and frontmatter tokens are preserved. Link-to-note effects retain labels and heading suffixes.
  • required-field: typed input for the field named by the finding: title (non-empty string), type (configured taxonomy enum), created/updated (real calendar date YYYY-MM-DD), tags (non-empty string array of lowercase, hyphenated tags). Only that key is edited; a minimal-editor refusal never falls back to serializing frontmatter. Other validation categories are manual.
  • manual: no content effect; file/line and explanation for an explicit edit, then check. blocker: reload configuration before starting review.

Content effects refuse non-UTF-8 source files (reason: "not-utf8") rather than normalizing unrelated bytes. Refusals carry reason; invalid field input additionally carries fieldError: {field, message} with format/enum guidance. Stale results carry reason and write no content or log. The fingerprint binds category evidence; previewToken additionally binds the handler, input and exact replacement bytes (including repeated token multiplicity), excluding unrelated file bytes. An unrelated edit after preview is preserved. An apply without a preview token is a usage error (exit 1). Neither token is an authority grant.

check_failed carries code, undoToken, changedFiles; the written effect remains, the finding stays open and nothing rolls back automatically. Undo requires the complete file to still match the effect's written bytes, offers its own inverse preview, and restores the exact pre-effect bytes only upon explicit invocation. Repeated Undo becomes stale. Repair receipts are Markdown under context/hygiene/repairs/<opaque-token>.md; they hold the before/after text and handler with standard valid frontmatter drawn from the configured taxonomy, with no authoritative state in brain.db.

Only a successful repair records resolved-by: handler:<name>; a successful manual recheck records resolved-by: check. Other findings keep their state and evidence. Reconciliation still reopens a resolved finding detected again. File validation failure, detector failure or unavailable resolution recording cannot report fixed. Unexpected storage/publication errors use the standard internal-error exit 2; an interrupted receipt requires recheck/reconciliation, not blind replay of a content write. The compare-then-rename writer retains its existing last-read-to-rename race limitation; these commands supply no multi-file transaction or server-level replay/authorization guarantee.

Human-started hygiene review (additive, #1027)

The authenticated host exposes GET /api/hygiene/review and POST /api/hygiene/review with strict { "operation": "start" | "pause" | "resume" | "refresh" } (and, additive in 0.45.0, undo-media: see media review effects). Both return { "review": HygieneReviewState, "action": InboxActionItem | null }. The read is operational state; start/resume select through the supported brain hygiene next --json boundary without inference. Owner and ambient principals may operate the review; delegated agent and system principals cannot. The normal auth/origin/body limits apply. Missing auth is 401, narrower authority refusal is 403, malformed commands are 400 and unavailable/failed CLI reads are review_failed (500). No new WebSocket capability is implied: clients discover these HTTP operations through this contract; Actions use existing inbox replication.

HygieneReviewState has version: 1, status (idle, active, paused, complete, blocked), nonnegative position, fixed, dismissed, snoozed, optional pendingActionId, optional C2 counts and optional CLI blocker. Additive in 0.41.0 (#1490): optional pauseReason is { kind: "actions-limit", retiredActionId, retirementReceiptId }. When the shared hard Actions cap drops the pending review card (including a snoozed card), its retirement and the review pause commit together. The review clears pendingActionId, returns action: null, and retains the dropped Action and shared FYI receipt through pauseReason. The three disposition counters and hygiene Markdown dispositions are unchanged; the original suppression remains. Read, reload, recovery and lifecycle sweeps never re-admit this card.

An explicit human start/resume re-admits the unchanged canonical finding under a fresh operational Action and thread identity, with freshly generated previews, through normal admission and the same hard cap. It clears the reason only when selection succeeds. Concurrent/repeated requests retain one pending card. If the fresh card immediately loses at the cap, the review pauses again with its new retirement receipt; there is no retry loop. A human Pause during in-flight Resume cancels that admission. Selection still reconciles through next; if that selects a different finding, the supported list read confirms that the retired fingerprint is still open before its retained finding is used. Changed or no-longer-open evidence follows the ordinary fresh selection instead. CLI failure preserves the paused reason for a later explicit attempt; a CLI blocker can coexist with it. Operational backups validate the referenced dropped hygiene Action and matching FYI receipt as well as pending/snoozed pointers. If a confirmed CLI operation is already in flight at retirement, its journal remains backed up with the dropped card and matching retirement receipt. Its actual result settles that journal without changing the terminal card or review disposition counters. Recovery checks an uncertain operation through the existing read/check path; it never repeats the write or re-admits the card. Retirement itself does not revoke principal authority or cancel an already confirmed write. A lost CLI receipt retains the started journal and releases the in-process guard, including when the card was dropped during dispatch. A later check-only recovery settles it without updating the terminal card or repeating the content write. Position counts presentations in this durable review, including a due snooze's return. The three counters count confirmed review dispositions; C2 counts retain their distinct backlog meaning. A reload or another device reads the same state. Manual pause neither disposes the finding nor changes its pending Action. Start/resume returns an existing pending Action. An empty selection returns deferred counts without an Action; a configuration/check blocker returns the CLI blocker instead.

refresh (#1493) explicitly re-reads the pending canonical finding through brain hygiene next --finding <id> --dry-run --json. This targeted form keeps the existing finding/counts envelope and returns current evidence/handlers for that detected open noninformational finding, even when another finding would win ordinary selection. An absent/ineligible finding is null. --finding requires a nonempty ID and --dry-run; malformed usage exits 1. Dry-run refreshes the disposable index but writes no Markdown, including hygiene logs. Counts reflect the existing durable log against current detection, without reconciling its dispositions. Ordinary next retains its selection and reconciliation.

If the fingerprint changes, the server prepares current handler previews and rechecks the finding after preparation. In one transaction it retires the old Action as dropped with a superseded outcome and replacement ID, and admits one new pending Action. Each Action retains its immutable finding identity, fingerprint and stored resolution effects. A fingerprint returning to an earlier value creates another new Action, never revives a terminal one. Supersession records no resolution effect, repair, dismissal, snooze or cap suppression; it writes no Markdown, changes no review counters or position, and never runs cap retirement of other cards. Existing inbox replication publishes both cards.

An unchanged fingerprint is a no-op returning the current card. A disappeared finding or intervening edit leaves the old card pending with stale; unavailable checks leave it pending with refused. An in-progress effect or concurrent Action revision refuses refresh (review_failed, 500) without replacement. Authority is rechecked after I/O. Every old-card preview or confirmation is refused, including requests from another device. A fresh card still needs a separately confirmed repair. Reload/restart retain the superseded receipt and new pointer. Refresh preserves a normal Pause; a review with no pending card (including cap retirement) requires explicit Start/Resume for admission, never Refresh.

An Action may carry optional hygiene: { findingId, fingerprint, finding, outcome? }. finding retains the supported C2/C3 projection, including the complete handlers descriptors, priority reason, provenance, presentation and invalidation receipt. outcome is { version: 1, status, reason?, code?, undoToken?, fieldError?, supersededBy? }; its statuses are applying, fixed, stale, refused, check_failed, still_detected, not_detected, undone, dismissed, snoozed, superseded. A superseded outcome also carries supersededBy, the replacement Action ID. The Action's ordinary version advances whenever its outcome or confirmed options change, emitting the existing upsert delta. Failed/uncertain outcomes survive restart and stay pending.

Stored options may carry input (the CLI descriptor: type is none, string, enum, date, strings or path, plus optional values/example) and preview ({ path, before, after, changes: [{ before, after, line }] }). A new v1 hygiene resolution effect carries operation (resolve, check, undo, dismiss, snooze), findingId, fingerprint, and optional handler, input, previewToken, undoToken. This kind is admitted only by the concrete human-review coordinator, never generic model escalation. Strict validation rejects extra authority fields. All existing effect kinds retain their meaning.

POST /api/hygiene/review/preview takes strict { itemId, optionId, expectedVersion, input }, where input is null, a string (maximum 4096 characters), or at most 128 strings (maximum 256 characters each). It runs C3's read-only dry-run, validates the current principal and item version again after I/O, then stores a new immutable option revision and publishes the updated Action. A successful response is that Action; failed handler validation is a persisted refused/stale outcome on the pending Action. Missing auth is 401, authority refusal 403, malformed data 400 and stale/unavailable preview preview_refused (409). Diff before/after/replacement strings follow MAX_PROMPT_CHARS; changes are bounded to 1024. Oversized previews are refused, never truncated into a confirmation. Input-free repairs are previewed at start; field/picker repairs require this endpoint before confirmation.

inbox_resolve adds optional input with those same bounds. It must equal the value bound to the stored preview; it never supplies an effect, fingerprint or new authority. Resolution uses the existing principal/option/frozen-revision checks and its generic refusal envelope. A content repair requires both the stored fingerprint and preview token, passed to C3. Only fixed completes a repair; stale, refused, check_failed and still_detected never advance. Done, check again completes only on not_detected. check_failed with an undo token adds a separate Undo change option with C3's inverse preview; confirmed Undo remains pending and invalidates forward previews. Nothing applies Undo automatically. Generic inbox_snooze refuses hygiene Actions: select their stored later option so the shared server calendar time reaches both Action waitUntil and markdown until: (CLI list.dueAt is the instant, while legacy list.until remains the day). Optional dismissal feedback uses existing reason.

A confirmed fixed/dismissed/snoozed disposition admits at most one next Action while active. Identical replays create no next item and run no second repair; conflicting terminal responses are refused. Due hygiene snoozes remain in Later until C2 selects them; the generic sweep cannot create a second pending hygiene card beside the current one. This review does not enqueue agent work.

The operational database journals confirmed dispatch before CLI I/O. Boot, or an explicit check after an unknown receipt, recovers interrupted repairs by reading the recorded finding/fingerprint and invoking check, never by replaying resolve. A not_detected recovery records fixed; a changed fingerprint stays stale. Interrupted dispositions are verified against CLI log state and their exact due instant. An uncertain inverse remains check_failed; it is not reapplied. Neither leaving nor disconnecting stops an already confirmed effect.

Managed worktree findings in the review (additive, 0.44.0, #2431)

When the host runs managed reference worktrees and core's hygiene functions load, the same review also selects host-only findings about those worktrees. They are never written to context/hygiene/ and never appear in brain hygiene list or any other CLI output; their findings and dispositions live in the host's operational database (decision).

  • A worktree is at most one finding, with category: "managed-worktree", path: "reference-worktree://<worktree id>", an ID and fingerprint from core's canonicalFindings, and an additive worktree object (id, domain, state, changedPaths, removal; a public worktree adds repository, revision, commit and sample). A private worktree's card names no repository, revision, commit, changed path or principal.
  • Its repair handler is delete-worktree (input none), offered for retained unfinished work and for a failed removal, never for a worktree whose holder, owner or work is unknown. Its preview path is the logical reference-worktree/<worktree id>, with an empty after.
  • start/resume pick across both backlogs by the CLI's ordinal rules and report summed counts; every operation on a worktree finding is answered by the host and never sent to the CLI. Dismiss, snooze and check keep the CLI's result shapes and fingerprint semantics; a dismissal or snooze never removes a worktree.
  • A confirmed removal is revalidated by the worktree service immediately before it deletes: the reviewer's authority, the exact native identity, no live or unknown holder, the reviewed fingerprint and preview against a fresh assessment. Any change returns stale. A removal that fails returns check_failed with code: "cleanup-failed" and a fixed reason; the same finding then reappears with a new fingerprint.

Owner-only private worktree details (additive, 0.44.0, #2734)

The owner principal may read what a private worktree's replicated card leaves out (decision).

  • HygieneReviewRead gains optional privateDetailAccess: { itemId }. The host adds it per request, to GET /api/hygiene/review and the POST command response, only when the caller is a usable owner principal and the pending Action is a private managed-worktree finding. It is never part of the replicated Action; its absence means no access.
  • GET /api/hygiene/review/details/:itemId returns HygieneWorktreeDetailsRead (hygieneWorktreeDetailsReadSchema), with Cache-Control: no-store. Every other principal, including the worktree's own delegated agent and any ambient principal, gets 403 { error: "Unauthorized" }; authority is read again after the host read.
  • { status: "details", details } carries owner ({ label, kind: "agent" | "device" }, or null for a removed, revoked or expired principal), repository, revision and commit (null when not recorded), changedPaths (null when the work is not established, never 0), the recorded sample, unverifiedPaths and assessedAt (epoch milliseconds, or null).
  • A card whose finding is no longer open, or that is not a pending private worktree card, reads { status: "gone" }; recorded facts that no longer match the card's fingerprint read { status: "changed" }. Neither carries a detail value.
  • The read uses recorded facts only: it never selects, checks, reassesses, previews or writes, so a finding's fingerprint, last_seen and state are unchanged. Clients keep the details in memory only.
  • HygieneCardProps adds optional details?: ReactNode, rendered between the evidence and the fix controls on the full card only. Its content is the caller's; the kit fetches and decides nothing.

Governed hosted workers (additive, 0.42.0, #1649)

Ordinary interactive and voice hosted turns default to the governed writable profile. Claude's native write tools and Pi's direct file tools are available subject to their existing tool permissions; voice retains its narrower roster. createApp accepts optional workerProfile: "governed" | "strict" to tighten ordinary turns. Autonomous turns remain restricted. Server application tools keep their approvals and exact-base semantics. Snapshot consumers execute configuration from complete read-only startup views with a separate validated write path, as ruled on #1649. In 0.42.0, startup inputs and publication copies shared a separate bounded output-staging allowance (256 MiB by default), admitted through the worker ledger; exhausted aggregate capacity refuses as worker_state_busy with Retry. BRAIN_UI_OUTPUT_STAGING_BYTES and BRAIN_UI_OUTPUT_STAGING_BUDGET_BYTES configure it. Invalid values refuse boot; oversized starting copies refused as worker_state_exceeded. Under the native storage requirement below, inputs use native checkpoints; writes beyond the filesystem allowance fail with ENOSPC. Internal launch/request fields remain internal.

Native hosted storage (breaking host requirement, #1790)

The maintainer approved managed Btrfs storage and its capacity scope on 2026-10-14. Hosted createApp requires ServerConfig.nativeBrain with socketPath, timeoutMs and maxDeltaBytes; environment resolution uses BRAIN_UI_NATIVE_BRAIN_SOCKET, BRAIN_UI_NATIVE_BRAIN_TIMEOUT_MS (30000) and BRAIN_UI_NATIVE_BRAIN_DELTA_BYTES (67108864). The last two values are positive finite transport bounds, not storage allowances. Optional leasePath (BRAIN_UI_NATIVE_BRAIN_LEASE_PATH) selects the persistent checkpoint ownership ledger; it defaults to DB_PATH.native-leases.sqlite and is required explicitly for an ephemeral UI database. Keep it across restarts, outside the brain. Readiness verifies that the configured brain is the manager's authoritative live subvolume. Unsupported, absent or mismatched storage refuses startup with setup guidance; there is no full-copy fallback.

The SDK ships the host-only brain-native-store launcher. It reads one fixed, root-owned configuration file and serves a Unix socket; it cannot provision, format or resize storage. Its manager operations and checkpoint identities are internal implementation details, not a new general filesystem API.

Read-only native checkpoints now supply complete immutable startup inputs. The 256 MiB output-staging allowance still bounds writable staged outputs, but no longer holds full input copies. Native checkpoints instead consume the separate, explicitly configured finite managed-volume capacity. Retained changed-file versions, journal metadata and object-write reservations retain the existing 10 GiB journal cap. No numeric total-volume default is supplied. See the storage decision.

Qualified worker identity (additive, #1677)

WorkerHostCheck and GovernedStatus gain the optional verified identity and UID fields described in the HTTP contract. WorkerNoteFacts.identity optionally carries { kind: "separate_uid", workerUid } from a successful host probe, so capability notes name the actual worker identity. Existing callers may omit these facts.

For separate-UID native recovery, the fixed root-owned brain-native-store configuration optionally sets workerUid to a positive UID different from appUid. It must match the qualified helper's UID. Native journal manager adapters may supply optional readCheckpoint, statCheckpoint and listCheckpoint operations; the server validates the fixed reader's UID before consuming private bytes. Existing adapters remain valid, with explicit unreadable results when they cannot recover a private input. These operations retain existing owned checkpoint authority and storage caps; they do not authorize arbitrary host paths. See the host boundary for descriptor transfer, supervision, cleanup and best-effort limits.

Reference pool storage (additive, #2428)

The root-owned brain-native-store configuration accepts an optional referencePool object for the separate reference source/worktree volume of the reference-worktree decision. It enables no consumer: no acquisition, worktree tool or worker mount uses the pool in this release, and ServerConfig is unchanged.

Field Meaning
storePath, devicePath, fsid Root-owned top-level mount, block device and UUID of a single-device Btrfs volume that is not the brain store.
combinedLimitBytes Combined allowance for source, worktrees and staging; default 10 GiB (10737418240).
operationLimitBytes Per acquisition/snapshot/worktree allowance; default 2 GiB (2147483648).
emergencyBytes Pool-local allocation released only to record recovery outcomes under ENOSPC; outside the combined allowance.
physicalFloorBytes Free space kept on the physical backing; never spendable budget.
headroomLedgerPath Required for sparse loop backing: a file in a root-owned directory on the backing filesystem, shared by every consumer of that physical capacity.
maxIdentities Bound on retained pool identities, default 4096, at most 65536.

All byte values are explicit positive multiples of 4096; invalid values refuse startup. The operator provisions the volume with simple quotas enabled (btrfs quota enable --simple, btrfs-progs 6.7 or later, or BTRFS_IOC_QUOTA_CTL with BTRFS_QUOTA_CTL_ENABLE_SIMPLE_QUOTA) before any data is written. Data, metadata and system profiles must each be single or dup. The mount must be read-write, without synchronous discard or user_subvol_rm_allowed, and appear only once outside managed root views. Loop backing must be a root-owned private file of the device's exact size and must not live on the brain store volume. Unqualified pools report their refusal; they never block brain storage startup or recovery.

brain-native-store pool status|recovery [--after <id>]|release <id> <config> queries the running manager over its socket and prints one JSON object with schema_version: 1. Success has ok: true, action and status, records/next or record. A manager refusal has ok: false, action, code and message and exits 3; usage errors exit 2. status reports qualification and refusal, configured limits, the accounting basis btrfs-space-info-disk-used+simple-quota-owner with its observation generation, unique aggregate allocation including profile replication, emergency allocation, charged, owner-attributed and global bytes, per-profile space, pending reservations, the reservation factor and available allowance, plus the physical backing's kind, unbacked bytes, ledger consumers and headroom. Records carry id, purpose, from, allowanceBytes, state (reserved, writable, sealed, deleting, deleted or reclaimed), root, path, ownerBytes and reason. The native manager client gains poolStatus, poolCreate, poolSeal, poolRelease and listPoolRecovery with NativePoolStatus/NativePoolRecord results; they are reachable through the existing client signature and add no request path, host path or general filesystem API. The lease owner adds the same pool methods, refuses deletion without a recorded lease, and reports retainedPool from recoverDeadOwners, which never deletes pool roots.

Reference publication (additive, #2429)

The native manager client and the lease owner gain poolPublish(id, view, name), poolUnpublish(view, name) and poolWithdraw(id). view is a caller-generated UUID naming one sharing domain; name is 1–128 characters of lowercase letters, digits and ._@+-, starting with a letter or digit, and never a UUID. Publish requires a sealed source root and returns its NativePoolRecord; unpublish returns { view, name, removed }; withdraw removes a published root's names, moves it out of its view and returns its record with view: null. NativePoolRecord gains view: the view whose directory holds a published root, else null. The lease owner publishes only roots it holds. Records from earlier pools read as view: null. Neither call accepts a host path. Worker masks now include the pool's roots and views directories, and a sealed root's top directory becomes 0755 root:root. The mechanism is recorded in the pool decision.

Reference source acquisition (additive, #2429)

ServerConfig.referenceSource? is { validationMs, refreshMs, operations }. The host edge resolves it from BRAIN_UI_REFERENCE_VALIDATION_SECONDS and BRAIN_UI_REFERENCE_REFRESH_SECONDS (default 300 each, 1–86400) and from BRAIN_UI_REFERENCE_ACQUISITIONS (default 2, 1–16); invalid values refuse startup. An absent field in a programmatic configuration uses those defaults. When the native manager reports a reference pool, createApp starts the reference source service and reconciles owned source roots at boot. It also cancels and withdraws private source when a grant or principal is revoked. No HTTP route, tool or worker mount exposes it in this release. The behaviour is recorded in the acquisition decision.

Managed worktree storage (additive, #2430)

The native manager client and the lease owner gain worktree checkpoints and access generations. poolCheckpoint(id, from) returns NativePoolCheckpoint: a sealed checkpoint record of the writable worktree from, with its native transaction and readOnly: true. poolDelta(from, to) returns the same framed metadata delta as delta, for two checkpoints of one worktree. poolReadCheckpoint, poolStatCheckpoint and poolListCheckpoint read a checkpoint through the fixed worker-UID reader, with the bounds of readCheckpoint. poolAccessOpen(access) and poolAccessClose(access) return NativePoolAccess ({ access, path, roots }); poolAccess(id, access) and poolPark(id) return the worktree's NativePoolRecord. access is a caller-generated UUID naming one worker generation. NativePoolRecord.purpose gains checkpoint, and records gain access, the generation holding a writable worktree, else null; earlier records read as access: null. poolCreate refuses the checkpoint purpose and a checkpoint as its source. poolRelease refuses a writable worktree whose record names an access generation with invalid, deleting nothing; park it first (#2729). The lease owner checkpoints only a held worktree, reads and diffs only held checkpoints, and moves only held worktrees; its lease table migrates in place. No call accepts a host path, UID or mount. Worker masks now include the pool's access directory, and brain-native-store pool records carry the new fields. The mechanism is recorded in the pool decision.

Reference and worktree tools (additive, 0.44.0, #2432)

@schlessera/brain-ui-sdk/tool-contracts (re-exported from /server) gains REFERENCE_SOURCE_CONTRACT, REFERENCE_WORKTREE_CONTRACT, REFERENCE_TOOL_CONTRACTS, their tool names and strict input schemas, REFERENCE_WORKTREE_ACTIONS, REFERENCE_WORKTREE_LIST_LIMIT (20) and the types ReferenceToolCall, ReferenceToolResult, ReferenceToolRefusalCode, ReferenceSourceView and ReferenceWorktreeView. Both contracts join BRIDGE_TOOL_CONTRACTS, after turn_temp, so BRIDGE_TOOL_POSTURE.names and allowedTools(adapter) end with reference_source and reference_worktree. SurfaceTools gains referenceSource and referenceWorktree. /server gains handleReferenceTool(name, input, bridge), which validates the input and returns { text, isError }, and BackendBridge.referenceAccess?(call), through which a backend hands the host only the tool name and parsed input. The tools themselves are specified in the MCP contract.

The native manager client and lease owner gain poolView(view), which opens one view's root-owned 0755 directory before its first publication and returns { view, path }; it accepts no host path and publishes nothing. /internal gains WORKER_REFERENCE_ROOT (/tmp/brain-reference), and the internal WorkerSandbox.references?: { public?, private? } binds those view directories read-only at public/ and private/ beneath it. The launcher refuses a view that is not a root-owned 0755 directory outside the brain, and any view in a restricted, maintenance or transport launch. createApp sets referenceWorktreeAccess whenever a reference pool exists. Pi's internal TOOL_RISK classifies reference_source as read and reference_worktree as mutate.

Hosted admission state (WsHost.governedTurns) and the server's contention lock (WsHost.workerLock) are internal wiring. They do not extend the supported embedder surface of WsHost; generated API reports retain that classification.

Session profile picker (additive, #1663)

ModelPickerProps.chat? adds the approved This chat native radio group and Limits disclosure: profile (governed/strict/null), disabledReason?, saving?, error?, running?, limits (facts or null), and onProfile?. Confirmed selection remains visible while saving or after failure; pending controls preserve focus without accepting a second save. Voice, offline, policy-locked and unavailable states show their reason read-only. The picker remains reachable for a fixed model without effort controls. Native keyboard behavior, 44px rows, Escape and return focus reuse the existing picker. SessionWorkerProfile and sessionWorkerProfileSchema are additive SDK exports.

Hosted policy review (additive, #1662)

The SDK exports PROPOSE_POLICY_CHANGE_CONTRACT, its name/input schema, PolicyChangeInput, POLICY_CHANGE_REVIEW_SCHEMA and PolicyChangeReview through tool-contracts and server. BackendBridge.proposePolicyChange? accepts a native tool-use request id and that input, returning the server-held review/receipt; absent capability means no hosted policy-proposal tool. PermissionRequest.kind additionally accepts policy; this never receives remembered or automatic approval. The machine behavior is specified in the MCP contract.

ApprovalCardProps.wrapActions? allows long decision labels to wrap into separate rows while retaining effective 44px targets. Omitted preserves the existing card layout. AgentPolicyRead carries read-only policy hash/source, validation notices, effective profiles, protected paths and the host's recorded worker-resource limits. Parsed budget/scope/egress values are not enforcement facts. No new extension seam or editor is introduced.

Completed inference budget facts (additive, #1669)

BackendActivityEvent.usage reports cumulative completed-response usage for ordinary interactive/voice turns; the existing autonomous variants retain their Queue semantics. budget_backstop reports Claude's SDK budget stop. StartTurnRequest.maxBudgetUsd passes the host's remaining turn limit to that SDK. ServerUsage, TurnBudgetReceipt, BudgetObservation and BudgetScope are the additive public wire types. TurnJournalSummary.budget shares the existing receipt; its list-price cost never changes the effective billing cost. TurnFailure.budgetScope supports the reviewed conversation handoff and UTC day refusal. These are facts on existing backend and protocol surfaces, not a new policy parser, budget provider or grant interface.

Scope lifecycle and decision facts (additive, #1670)

The SDK protocol exports ClientScopeDecision, ServerScopeDecisionReceipt, ScopeDimension, ScopeCapReceipt and TurnScopeSummary. TurnJournalSummary.scope is optional. StartTurnRequest.workerStarted is a server-only parent lifecycle callback; it is never serialized into workers and never accepts a client/model capability. Both first-party adapters notify it with the launched worker's concrete freeze/resume/kill/exit handle. WorkerSandbox.scopePolicy feeds the existing capabilities-note generator from the immutable admitted policy; WorkerNoteFacts.hardTimeoutMs states the independent hard deadline. Kit AskUserOption/ChoiceOptionProps.disabled make pending/offline scope options inert with a readable reason supplied by the surrounding card. These additions ship in a minor; the wire behavior is specified separately.

Optional transcript diagnostics and recovery (additive, #1806)

@schlessera/brain-ui-sdk/server exports createTranscriptStore, the existing TranscriptStore interface, and the additive RecoverableTranscriptStore, TranscriptDiagnostic, TranscriptRecord, TranscriptMetaRecord, TranscriptMessageRecord, TranscriptFailure, TranscriptRecoveryProvenance, TranscriptCorruptionError and TranscriptRecoveryError. The factory returns RecoverableTranscriptStore; existing third-party TranscriptStore implementations gain no required members. Healthy create, append, list and history signatures and results retain their existing shapes.

A malformed JSON/UTF-8 line or unsupported record quarantines that file in place. Required metadata is the first nonblank record: kind: "meta", an id matching the filename, nullable string title and finite nonnegative createdAt. Later records have kind: "message", finite nonnegative ts, a supported SessionHistoryMessage and optional finite nonnegative costUsd. Unknown additive properties remain intact. Empty files, missing/wrong metadata and repeated metadata are corrupt. Blank lines are allowed. Valid final JSON without a newline is healthy; append supplies a separating newline. Admission also refuses malformed optional fields that the wire reader would normalize or discard: a verified raw prefix must remain valid without alteration.

list() excludes quarantined files, without aborting healthy enumeration. history() and append() on a quarantined file throw TranscriptCorruptionError (code: "TRANSCRIPT_CORRUPT", diagnostic). Unknown history remains empty; unknown append still refuses. Filesystem errors other than absence propagate rather than being classified as transcript corruption. No operation silently repairs, truncates, relocates or logs original content.

  • diagnose(sessionId) returns { sessionId, status: "missing" } for absence. Otherwise it returns sessionId, sourceVersion (SHA-256 of all source bytes), prefixBytes (exclusive verified byte boundary), and records (the contiguous admitted records, with original objects). Healthy results have status: "healthy", complete: true; quarantined results have status: "quarantined", complete: false, failure. The failure carries one-based line, zero-based byteOffset, position: "final" | "interior" and reason: "invalid-json" | "invalid-record" | "invalid-utf8" | "missing-meta". Position describes whether nonblank bytes follow the failed line, not a guessed crash cause. No records after the first failure are admitted.
  • quarantined() explicitly enumerates only quarantined diagnostics, including their qualified prefixes. Ordinary history never presents these as complete.
  • recover(sessionId, { sourceVersion }) is explicit authorization for the inspected version. It returns a new session id, with metadata title Recovered: <original title or source id> and recovery provenance containing sourceSessionId, sourceVersion, prefixBytes, prefixRecords, and the original failure. The original identity/bytes remain unchanged. Message lines, timestamps, costs and unknown properties are copied byte for byte; only metadata is replaced, with a trailing separator added if needed. Provenance persists in metadata and is accessible through diagnostics; normal ChatSession and history shapes gain no required fields.

Recovery refuses missing/changed source with TranscriptRecoveryError (code: "SOURCE_CHANGED"), healthy source or missing valid metadata with "NOT_RECOVERABLE", and incompatible existing recovery content with "RECOVERY_CONFLICT" (corrupt existing recovery raises TranscriptCorruptionError). It hashes the full source at admission and again around publication, refusing observed concurrent edits. Callers must serialize external transcript edits with recovery: this is optimistic filesystem validation, not an OS lock against arbitrary writers. The store itself never appends to the quarantined source.

Recovery validates its complete output, writes and fsyncs a hidden .pending file, atomically links it without replacing an existing target, then fsyncs the directory. The target identity is deterministic for source identity/version; concurrent attempts and retries converge on that identity, including after subsequent valid appends. Interrupted staging cannot appear in list(); a process crash can leave hidden staging evidence. A failure after publication may leave a complete labelled recovery, and retry validates it and syncs the directory instead of creating a duplicate. A different source version requires a new inspection/authorization and represents a distinct recovery.

Only optional plain API-loop backends use this helper. No shipped backend currently calls it; Claude/pi keep native persistence. Adopting backends keep normal listSessions/getHistory delegation, catch the typed per-session corruption outcome, and invoke diagnostics/recovery only through deliberate host-owned actions. They expose the returned title unchanged and resume the new returned identity. No wire frame, HTTP route, UI destination or native backend recovery interface is added by this SDK change. These supported additions ship in a minor under the recorded #1806 rulings.

Custom Other drafts (additive, #1887)

AskUserCardProps in @schlessera/brain-ui-kit gains optional otherText?: string and onOtherChange?: (text: string) => void for a controlled custom answer draft. Without them the card keeps a local draft. While Other is open, the persistent Submit action invokes the existing onOtherSubmit callback with current nonblank text, as Enter does. Blank text disables Submit with an explanation. showActions={false} and closed exchanges still omit actions. The client preserves selected predefined answers beside custom text in a multi-select and the existing grouped-question submission path. No wire, CLI/MCP or answer-delivery contract changes.

Backend Storage roots (additive, #2010)

AgentBackend.storageRoots?() is a synchronous host-only observation of already owned transcript/archive roots: readonly {kind: "transcripts" | "archive"; path: string}[]. It performs no setup or provider discovery. The server consults only already initialized owners, never passes these paths to a worker bridge, and converts them into opaque root IDs before transport. Older backends remain valid and report unavailable transcript accounting. Claude supplies its exact brain-specific live/archive leaves; Pi supplies its already resolved session root.

Host Storage reader (additive, #2010)

@schlessera/brain/storage supports host-shell authors who need bounded live physical metadata without evaluating a content repository. The operation readStorageInventory({brainPath, gitRoots?, signal?}) returns a promise of the version-1 Storage inventory envelope, with the existing 100,000-entry/30-second limits and isolated/reaped filesystem worker. brainPath is absolute; gitRoots contains at most sixteen explicitly approved absolute external stores. Invalid roots reject before traversal. The host must enforce user authorization; this is not an agent-facing filesystem browser.

This inventory operation executes neither Git nor the brain CLI, and loads no repository configuration/providers. It reads only fixed Git location markers (.git, commondir, objects/info/alternates) through bounded non-followed descriptors, approving external stores before reading their markers. It supports ordinary worktrees and linked-worktree/alternate layouts; unsupported markers, redirects, mount transitions and encodings remain partial evidence. It does not implement Git configuration semantics or discover a repository from parent directories. Host-selected root aliases retain the inventory's canonical-root semantics.

The ordinary standalone brain storage inventory CLI retains its existing Git resolver contract and must run through the maintenance boundary when invoked by a governed server. The HTTP inventory uses the public metadata reader so it measures the canonical live objects, not a maintenance worker's native copy. The same structural inventory schema is consumed; available allocation is still a qualified charge and never a reclaimability guarantee. Bun/types/default exports resolve source/declarations/build from the same release. Older core installations without this entry produce unavailable inventory, with no CLI or internal-import fallback.

Storage maintenance protocol (additive, 0.44.0, #2011)

@schlessera/brain-ui-sdk/protocol exports StorageMaintenanceKind, StorageMaintenanceRequest, StorageMaintenanceItem, StorageMaintenanceObservation, StorageMaintenanceReceipt and StorageMaintenanceRow. These describe the three fixed host-owner operations, caller-owned durable identity, qualified state/outcome, headroom disclosure and independent store/capacity measurements in the maintenance HTTP contract. They do not add an agent tool or generic executor. Existing automatic retention and owner APIs retain their previous defaults.

Selected attachment application (additive, 0.44.0, #2012)

@schlessera/brain/storage additionally exports STORAGE_ATTACHMENT_LIMITS, StorageAttachmentRefusal, storageAttachmentPath, inspectStorageAttachment, removeStorageAttachment, restoreStorageAttachment, storageAttachmentVacant, inspectStorageAttachmentFacts, and the StorageAttachmentVersion and StorageAttachmentFacts types. The inventory reader keeps its existing contract.

Raw-file operations bind an absolute canonical Linux root, relative path and exact descriptor-derived version (hash, size, mode, inode/stamp and parent chain). They refuse symlinks, aliases, nested mounts and executable/protected targets. The caller must own exclusive writer coordination, current human authorization, verified durable recovery and intent before calling removal. Its optional final assertCurrent callback revalidates authority immediately before unlink or restore; restoration uses a caller-leased verified original and never overwrites a present entry. These primitives neither provide a journal nor change its retention policy. Raw hashing supports cancellation and bounded file size/time.

inspectStorageAttachmentFacts(root, paths, signal?) evaluates Git and bounded current Markdown. Hosted consumers must run it inside their qualified maintenance worker and discard outputs. It is not a privileged host-side Git reader, and its unchecked scopes prevent interpreting an empty reference list as proof of no consumers. Core semantics have no dependency on the UI database. Server contracts and bounds are defined by the removal HTTP contract.

@schlessera/brain-ui-sdk/protocol exports StorageRemovalInspection, StorageRemovalReviewRequest, StorageRemovalStartRequest, StorageRemovalUndoRequest, StorageRemovalFile, StorageRemovalReview, StorageRemovalItem, StorageRemovalReceipt and StorageRemovalCapability. They contain relative paths and opaque identities, never private recovery roots, executable choices or credentials.

Storage presentation (additive, #2013)

@schlessera/brain-ui-kit exports StorageMap, StorageMapItem and StorageMapProps. Items supply opaque identities, names, ownership (brain, app, operator), known disjoint bytes, a size/share label and optional smaller/interactive flags. Props supply those items, CSS-pixel width/height, an accessible group label and description ID, and onOpen(id)/onUp() callbacks. The component performs no I/O; callers own the bounded, deduplicated inventory and equivalent complete list. Unknown and nonpositive sizes have no tile area; small tiles have no activation target. It preserves one roving tab stop.

The ui-react Settings selection accepts storage. openSettings("storage") and the existing palette open the same lazy pane after Modules; Models remains the default. This adds no main destination or keyboard shortcut. Requests and local selections remain scoped to the current root and authentication lifetime.

Storage history presentation (additive, #2059)

AppShell includes a persistent owner-scoped Storage status. It and the lazy Settings pane consume named server guidance; there is no client warning or forecast engine. useHashRoutes() accepts #/settings/storage after a current authorized guidance read, preserves Settings leave guards and clears the link when Storage closes. These additions create no navigation destination, keyboard shortcut or new exported component. Evidence is isolated by root, account and authentication epoch; forbidden responses clear it.

Claude cumulative usage evidence (additive, #2025)

The server-only BackendActivityEvent variant claude_session_usage carries optional completed: TurnUsage, cumulative: TurnUsage and reportedCostUsd: number. completed is this host turn's completed root/child response usage; cumulative and reportedCostUsd are source-qualified runtime session totals, never turn receipts. Repeated events refine one host turn; they do not represent additional spend. An absent cumulative total or completed receipt stays unknown. The host persists a conservative auxiliary high-water reconciliation separately from main usage, without assigning title-helper provenance or relying on a model name. Direct backend consumers receive own completed token counts on result; its absent cost remains unknown until a host prices those counts. Autonomous Queue semantics are unchanged.

Content-renderer entries (additive #2081)

The experimental @schlessera/brain-ui-sdk/client content seam exports ContentRenderer, ContentRendererProps, ContentRendererPack, ContentRendererRegistry, ContentRendererTheme, createContentRendererRegistry, defaultContentRendererRegistry and registerContentRenderers. Components receive source, optional path, mode (chat | file | share) and theme (light | dark | print). Exact, case-sensitive language and extension claims resolve first-registration-wins. Extensions include the leading dot. Pack object identity dedupes registration; reset clears claims and identities. A missing lookup returns null.

The React/DOM-independent @schlessera/brain-ui-sdk/render exports StaticRenderer, StaticRendererPack, StaticRendererRegistry, ContentRendererTheme, createStaticRendererRegistry, registerStaticRenderers and staticRendererFor. Static entries carry claims, needsBrowser, and toStatic(source, { theme, path? }): Promise<string>. The default lookup checks language before extension on ambiguity. Client and static builds register independently; neither loads code named by content/runtime configuration.

The experimental @schlessera/brain-ui-sdk/testing exports contentRendererConformance(pack, fixtures), ContentRendererFixture and ContentRendererFixtures. A real-browser mount harness lets the suite exercise client components in every mode/theme and static output in every theme. The authoring guide specifies the token block/root ground convention. Conformance tests trusted bundled code; it is not a sanitizer. Dispatch, manifests and concrete packs are separate epic work.

Stable hosted application roster (#2048)

The additive, trusted StartTurnRequest.applicationAuthority?: "server" field identifies a deployment whose authoritative mutations use the server route. The UI server supplies it on every interactive turn, independent of posture, strict fallback or temporary bridge handler availability. It is session-stable host configuration, never client/model input or a grant. Claude uses it to keep legacy project MCP writers shadowed even when applyBrain is temporarily absent; its fixed in-process application tools then refuse unavailable routes at call time. Direct unhosted consumers omitting it retain their legacy project MCP roster. Existing custom bridges without the field retain the prior applyBrain presence fallback. Autonomous authority/rosters are unchanged.

Deferred application indexing (breaking, 0.44.0, #2835)

BrainApplicationResult adds indexStatus?: "pending", and indexed now means the index covers the result's committed changes: a host that defers the update reports indexed: false with indexStatus: "pending" and no error, as specified in the application contract. BackendBridge adds the optional awaitIndex(toolName). A backend awaits it before admitting any tool, under the name the runtime calls it, and refuses the tool with the rejection's message; the host resolves at once for tools that do not read the index. The Claude and pi backends call it from their every-call admission paths (the PreToolUse enforcement hook and the shared pi permission decision). A custom backend that never calls it can read an index that misses applications its turn already committed.

A turn's data tree (additive, 0.44.0, #2848, #2846)

BackendBridge.awaitIndex takes an optional second argument, the tool's input where the backend has it: awaitIndex(toolName, input?). The host may also refuse a read it cannot serve current. The first-party Claude backend passes tool_input from its PreToolUse enforcement hook, and the host refuses a Claude Read of a brain path this turn applied, naming mcp__brain__brain_read. A backend that omits the input keeps the earlier behaviour.

The internal WorkerSandbox gains optional @internal dataTree (WorkerDataTree: { root, index, indexWritable?, masked? }, exported from /internal with WORKER_DATA_ROOT, /tmp/brain-worker-data), and StartTurnRequest gains the matching @internal workerDataTree for the restricted envelope, as workerProviderMasks. The launcher mounts root read-only at WORKER_DATA_ROOT and index read-only over its .brain-index/, repeats the provider masks there, and sets BRAIN_ROOT and BRAIN_WORKER_DATA_ROOT to WORKER_DATA_ROOT and BRAIN_WORKER_CONFIG_ROOT to the brain path. Only a worker on an immutable startup view may receive one. WorkerProviderMasks gains @internal indexWritable: the private index is now mounted read-only unless the host's own build sets it. The first-party pi backend reads its brain tools, read_file, grep and brain_read from the tree and runs its shell there. In @schlessera/brain-ui-server, GovernedTurnHold adds dataTree?: TurnDataTree. AppliedEffect's content and base also accept a Uint8Array: an image mask's PNG reaches the tree through the same commit (additive, #2892).

Prompt-cache diagnostic events (additive, #2050)

BackendActivityEvent adds prompt_prefix (prefix: PromptPrefixFingerprint, resumed: boolean) once before the first request, and first_request_cache (usage: FirstRequestCacheUsage) once for the first main request's receipt. Both use the existing BackendBridge.activity channel and are observational, never permission grants or provider-cache promises. Raw assembly text must be hashed within the backend. Missing input components/counters stay null. The host validates digest/counter shapes, copies only documented fields and retains the first receipt; invalid or duplicate evidence cannot overwrite it. Title helpers and child channels must never be reported as the first main request. Their separately retained accounting remains independent.

PromptPrefixFingerprint, FirstRequestCacheUsage and ActivityCacheDiagnostics are additive protocol types; their HTTP meaning and retention limits are in cache diagnostics.

Module content renderer declarations

ModuleContribution.renderers?: Record<string, ModuleRenderer> is an additive module input. ModuleRenderer has optional fences: string[] and extensions: string[], required client: string and optional static: string. Declarations are strict: unknown fields fail loading. Fence names match ^[a-z][a-z0-9-]*$; extensions start with .. Both entry specifiers name export subpaths of the declaring module's own package, verified against its package.json name.

Duplicate active fence/extension claims fail load naming both module owners. Dormant declarations do not claim dispatch names. Built-in Mermaid reserves mermaid, mmd, .mermaid and .mmd. Module lint resolves entries and requires the static entry's named toStatic export to be a function. Ordinary loading does not execute renderer entries. No runtime browser loading is added. See modules and the renderer decision.

Claude title-helper evidence (additive, #2052)

The server-only BackendActivityEvent gains claude_title_usage with coverage: "observed" | "partial" | "unknown" and receipts, an array of { receiptId: string; source: "generate_session_title"; usage: TurnUsage; costUsd?: number }. It follows worker/exporter shutdown and precedes release of the held terminal result. It does not replace or add to the existing claude_session_usage.completed root/child receipt.

Purpose comes from the installed runtime's supported OTLP API-request event, not a model name. Receipt identity deduplicates exports/replays; measured helper usage is allocated only against the matching newly reconciled auxiliary delta in the session transaction. A separate Claude session title Activity run retains that usage/cost, while unmatched usage remains unattributed and main/cache usage retains its existing completed-response semantics. Missing or unjoinable evidence stays unknown/partial, not a free helper or an inferred main-model charge. Operator telemetry destinations and opt-outs are preserved; without a local receipt, ordinary unattributed accounting still applies.

Built-in Mermaid content renderer

The built-in browser pack claims mermaid / mmd fences and .mermaid / .mmd source files through the content-renderer registry. Its static entry requires a browser and produces self-contained vector SVG without HTML foreignObject labels. Shared PNG/PDF output and browser print use the kit's opaque white print palette. Light and dark screen palettes remain unchanged.

inlineMermaidDiagrams(markdown, theme?) remains a compatibility entry point; it now dispatches all bundled browser-only content renderers and defaults to print. An explicit light or dark argument still selects that palette, and print is an additive accepted theme. shareMarkdown retains its inlineMermaid dependency name for existing callers. Failed or oversized static rendering preserves the source fence and reports a lint warning.

Deployment renderer registration (additive, #2087)

@schlessera/brain-ui-react exports experimental registerContentRendererPack(pack, { name, version }), accepting the SDK's ContentRendererPack plus the bundled module manifest name and package version. Call it before mounting. Registration is build-time trusted code, identity idempotent, with first-claim dispatch. It never imports server-named code. @schlessera/brain-ui-sdk exports ContentRendererDeclaration for the authenticated bootstrap metadata.

Native persisted Claude memory observation (additive, #1658)

StartTurnRequest.persistClaudeMemory is an optional host-only callback for validated native Claude memory copy-out. The backend supplies a direct note, its host-owned external directory, exact before/after bytes and before mode, and a synchronous save closure. A null observation explicitly reports copy-out that could not be inspected before a note callback. It is never serialized into a worker or accepted from a client. The first-party server retains/pins versions and a private pending receipt before invoking the closure, then publishes a steering change only after the save succeeds. Failed or interrupted finalization remains partial/unconfirmed. Other backends need not implement Claude native-memory persistence; this adds no agent history authority or new public request route.

The callback may return a promise (additive, #2814): the first-party server commits its journal records on a write worker, off its event loop, and invokes the save closure only after they commit. A backend awaits the returned value before it persists the next note or ends the turn; a callback that returns nothing behaves exactly as before.

Journal and Activity writes commit off the event loop (#2814)

Ruling 1 on #2789 moves the hosted server's journal, Activity and native-lease commits to a write-worker thread at synchronous=FULL, and makes their callers asynchronous. On the TurnJournal reachable through WsHost.journal:

  • writeOperation takes statements (JournalStatement[]) instead of a callback, and resolves with each statement's change count once they commit as one accounted write.
  • bindSession, persistClaudeMemory, recordClaudeMemory, reserveRevert, finishReserved, withRetainedRemoval, withRetainedVersion and version().stream() return promises that settle after their commits. bindModel still answers synchronously, from the active turn's binding.
  • ready settles once startup recovery has committed. It rejects with the refusal that createTurnJournal used to throw.

AwaitableActivityStore is the Activity store as the hosted server holds it. Its reads are the ActivityStore reads. Its writes may return promises that settle after their commit. An ActivityStore, whose writes all commit before they return, satisfies it. Refusal classes and transaction order are unchanged. These shapes change at a pre-1.0 minor under that ruling.

Late Activity session association

ActivityStore.patchSpan accepts an optional sessionId to bind an unnamed active span when the backend reports its session after the first frame. Repeating the same identity is allowed; changing an established identity or patching a terminal span returns false without modifying the row. The recorder persists this identity in both the queryable session column and the existing attributes. This additive API ships in a minor release.

Backend host exec configuration (additive, #1429)

BackendModuleContext, ClaudeBackendOptions, CreatePiBackendOptions and BackendRegistryOptions accept optional exec: ExecWrapperConfig. probeBackendRuntimes accepts the same optional configuration as its sixth argument. The app passes its resolved ServerConfig.exec to startup probes and backend construction. Both first-party backends use that host configuration for runtime probes, Claude SDK workers and Pi grep/bash tools, including tools inside Pi workers. An explicit empty object disables the optional wrapper; an absent field preserves ambient-wrapper compatibility. Standalone backend factories preserve omission, resolving the ambient wrapper and killer at the existing turn/tool-spawn boundaries; Pi serializes that host fallback into each worker turn. Explicit host configurations are copied at construction.

A Pi worker's view gains each configured helper file read-only at its configured path, and nothing else of the directory holding it, in ordinary and restricted workers alike. Each helper a worker runs, the Pi wrapper and killer and the ordinary Claude worker's wrapper, must be an absolute path naming a regular file, directly or through a symlink; anything else refuses the turn before any relay, reservation or worker is acquired. Existing argv, environment and permission semantics remain unchanged; the optional wrapper never replaces worker isolation.

Module metadata admission cancellation (additive, #2457)

BrainClient.modules({ signal }) accepts an optional abort signal. It cancels metadata waiting for execution admission, including a required CLI version probe. Once an execution owns its admission locks, its worker, output publication and cleanup drain normally. Cancellation between a version probe and the module lookup prevents the latter from starting. Calls without an option preserve their existing behavior and result shape.

The internal host boundary carries a separate admission signal so existing execution cancellation, including search worker termination and output drain, keeps its semantics. The optional BrainClient.modules input is an additive public TypeScript change and ships in a minor.

createApp() passes each /api/status and /api/modules request's signal, so a caller that disconnects cancels its queued metadata. close() cancels every metadata request still waiting for admission as soon as it begins. Admitted metadata is a shared run: it drains within close()'s 10 s grace period and is then cancelled with the other shared runs (#2473). The response shapes and status codes are unchanged.

Pi provider sign-in export (additive, #2485)

The @schlessera/brain-backend-pi root exports createPiAuth(options?) again, with the types its signature reaches: CreatePiAuthOptions, PiAuth, PiAuthProviderStatus, PiAuthRuntime, PiLoginFlow and PiLoginFlowStatus. PiAuth is the headless OAuth sign-in service ui-server's /api/pi-auth/* routes drive through the optional backend loader. The curated root dropped it in 0.41.0, so those routes threw on every request; restoring it changes no HTTP shape.

The #3070 contextual conversation amendment adds optional contextualTasks evidence/open negotiation and LiveConversationSession.bindContextualWork. ConversationWorkRef and receipts discriminate contextual task/native identity from reviewed utterance identity; contextual work has no utteranceId. Result acceptance may explicitly be unknown; returnWork can report acceptance evidence. Unassigned fragments may carry explicit unavailable timing and native finalization evidence instead of a provider interval. Exhaustive consumers must handle unknown work/delivery and voice-context message provenance. These pre-1.0 breaking changes ship in a minor under the October 28 #3070 ruling; see the normative wire amendment.

Conversation output recovery and control ownership (#3053)

The React root's conversation client exposes outputRecovery independently of micOn and recoverOutput() for an output-only timeout. attachControls() returns an idempotent release callback; a same-commit control remount retains ownership, while releasing the last owner ends conversation at the microtask boundary. Cleanup is not a manual Mute. The client lifecycle defines the breaking semantic change from repeated Resume to continuous consent.

Manual Send, reviewed question answers and Discard end input through bounded output recovery rather than leaving outputRecovery in speaking. Discard retires the reviewed input identities; late recognition cannot restore them. These actions and Mute cancel pending automatic review timers, while output recovery remains independent of microphone consent.

Retained dialogue data (#3177)

The SDK protocol exports DialogueEvidence, DialogueRecord, DialoguePage, ClientDialogueRead and ServerDialogueHistory, with runtime schemas in the existing schemas entry. These are data-only retained history, not provider-native messages or task admission. Their identities/bounds/coverage and the breaking retention supersession are defined in the wire component. Optional history-owner metadata on conversation open/start and plain chat input is additive; legacy backend session IDs and native provider guards keep their meaning. No provider history-selection API is delivered by this slice.

Failed-write live text exposes optional ServerConversationEvent.retention and ServerError.historyOwnerId, plus the existing client projection's explicit unsaved recognition/output fields (#3177). These are additive evidence and failure-scoping data. DialogueJournal.voice reports commit success to its callback; current-epoch text remains observable when the callback reports a failed write. No provider-facing history or authority interface changes.

Existing-chat live context (#3178)

LiveConversationOpenOptions.history?: ConversationHistoryEnvelope is an additive input on the existing live-provider seam. Version 1 contains ordered HistoricalItem values and coverage with explicit HistoryGapReason values. Optional host-only newestRefs identifies the possible newest items after exact causal joins; it qualifies omissions and never enters provider text. Typed chat (including recorded dictated/submitted-voice source), uncertain recognized speech, generated voice replies, completed host facts and gaps remain distinct. HistoricalResultRef preserves the exact conversation/epoch/request/turn join internally; all source references, provider identities and operational metadata stay out of provider text. Recognized speech is never authenticated operator dialogue; generated text never proves playback. Historical items grant no admission or permission.

The host reads canonical journal pages after authorization and known-chat admission, joins native backend-owned answers only by exact position witnesses, and reads completed facts through exact retained receipts. Live user items require recorded source provenance: exact turn identity, or ordinal matching with equal full native/source duplicate counts when turn IDs are unavailable. Unproven user items become in-place source_unavailable gaps with partial coverage; ordinary chat display keeps its existing fallback. Native assistant text remains historical data. This excludes contextual task wrappers regardless of journal paging or receipt retention, as well as raw audio, hidden thinking, tool requests and native response handles. Missing cross-stream position evidence qualifies order, without replacing full native answers with range copies. A read failure refuses startup; there is no automatic provider retry or context-free fallback. Authorization, entry lifetime and owner/generation are rechecked after reads.

Both first-party adapters freeze one historical-data seed per native open. Selection uses a combined exact serialized UTF-8 text ceiling, framing and omission markers included: Gemini 24,000 bytes/16,000 fact bytes; GPT 7,500/5,000. GPT's byte ceiling estimates headroom under its documented 8,192 rendered-token limit; it is not a tokenizer proof. History previews within one-third of total, reserving only actual bytes. Resync uses the remainder with the explicit fact cap; history is selected again from remaining total/fact bytes, excluding only exact result identities actually emitted by resync. Newest request/state and newest historical discussion take priority, then whole facts (resync first), then older discussion. At most 128 historical items are selected. Whole omitted items leave in-place gap markers and coverage; tailOmitted reports an omitted newest item, conservatively also qualifying discarded text whose cross-stream recency is unknown. Partial coverage uses “may be missing” copy rather than a claim about the exact newest item. These bounds never delete the display archive.

LiveConversationSession.historyCoverage? exposes the adapter's actual bounded historical selection as {state, tailOmitted}. Third-party providers may ignore the additive history input but cannot thereby claim existing-chat continuity. The frozen provider prefix/tools/instructions are never rewritten within an epoch.

The reachable SessionCatalog.attachMessageSources signature also accepts optional strict?: boolean. The default preserves ordinary display recovery's existing fallback. true propagates source-read failures for live startup so internal task carriers cannot be reclassified as typed chat after a read error.

WsHost.prepareHistory(sessionId, messages, strictSources?) performs strict source joining in the ordinary position, after exact native turn/failure identity joins. It must not normalize file-bearing user bytes before those prefix digests match. Unwitnessed native prefixes precede the earliest journal evidence as a selection policy, not a chronology witness. Later unpositioned runs follow their native predecessor before the next witness. Exactly witnessed assistant ranges stay at their recorded positions even when their request is unwitnessed. Unknown order always carries a gap/partial coverage; missing request records in a partial source do not prove that a turn predates the journal. Bounded selection preserves the newest journal discussion and conservatively qualifies discarded ambiguous text. An exact contextual receipt/turn/submitted-text join excludes a task carrier when its asynchronous source metadata write failed, without filtering unrelated equal user text. Any exact unclassified carrier without a provable native answer/nearest- user join fails the snapshot closed, regardless of task state or later native turns. Receipt lookup also requires the exact conversation, principal and session.

ConversationProfile and conversationProfileSchema are public SDK exports for {providerId, capabilities, disclosure}. Hello may advertise this profile and Start may echo it, with the intentional legacy admission refusal documented in the wire component.

The host helper ConversationHost.profile(contextualTasks?) supplies the same known provider-free effective disclosure for hello, admission comparison and opened receipts. The host refuses undisclosed legacy starts before touching their scope; the wire section records the deliberate privacy admission compatibility correction.

Indeterminate storage settlement (#3185)

StorageSettlementIndeterminate is an additive error export from @schlessera/brain-ui-server. Its operation: string names the bounded wait; optional cleanup?: Promise<unknown> observes the original owned operation through its genuine eventual success or failure. Awaiting the error's cleanup is optional and can remain pending indefinitely. Neither the error nor a rejected close proves persistence, cancellation, worker exit or resource release.

BrainUiApp.close() refuses new admission at once and monitors real outstanding requests on both its shared UI/journal SQLite worker and its separate native-lease SQLite worker throughout cleanup. Native manager RPCs do not acquire a SQLite deadline. A request pending at close gets ten seconds from observation; a later submission gets ten seconds from submission, including native FIFO queue wait. An unsettled native request rejects close while retaining the actual cleanup continuation. This is not a whole-app wall-clock deadline: protected CLI writers may outlast the CLI grace, healthy reads canceled at the end of that grace still let close resolve, and Activity preserves its own transport deadline and exact genuine error identity. Close keeps one public outcome across repeated calls. Borrowers drain in their existing order, then the actual writer closes before the reader. No worker termination or early reader close is introduced. A genuine cleanup failure that settles without a native storage deadline keeps its original identity; later cleanup errors remain observable through cleanup. The deployment shell owns physical process exit. WsHost.close() likewise bounds dialogue settlement and rejects indeterminate storage without releasing the actual journal or writer ownership.

This is an additive diagnostic/lifecycle clarification in a minor release. Existing successful close, FULL durability, archive retention and no-replay guarantees remain. The error supplements existing close rejection rather than turning a timeout into successful disposal.

Local recording duration (breaking before 1.0, 0.44.0, #3225)

The maintainer ruling selects ten minutes of actual audio and a separate 660,000 ms monotonic backstop. Exported LocalCaptureChunk.startMs/endMs and Recording.durationMs, savedThroughMs and interruptedAt now measure complete emitted WebM/Opus packet audio. Unsupported codec/container forms, including audio/mp4, retain conservative elapsed-wall timestamps for the rest of that recording. Old stored wall timestamps remain readable without migration. No field, stop reason or state is added. This semantic change ships in a minor before 1.0.

Composer attach label (additive, 0.45.0, #3045)

ComposerProps in @schlessera/brain-ui-kit gains optional attachLabel?: string, the attach trigger's accessible name. Omitted or empty, it is "Attach — photo, camera, files" (previously the fixed "Attach — photo, camera, file"). An actionable trigger also paints that name as an aria-hidden pointer tooltip; an inert trigger, including a dictating composer's, has neither. Existing props and defaults are otherwise unchanged, and no wire schema changes. api-report/ records the added prop.