跳到正文

The persistence map: files, storage domains, and the host/client split

Status: Superseded in part by ADR-0041

The persistence map: files, storage domains, and the host/client split

Every piece of BotHarness state now has exactly one named home. Bot identity, persona, and memory stay user-owned files under $DSH_HOME/botharness/bots/<personabot-id>/ — portable, git-versionable, and the future package unit for SoulSnapshot/registry (M6/M7). Messaging facts and the Messaging Policies whose exact versions must join their commit move into the BotHarness-owned SQLite store defined by ADR-0037: one canonical Source Event body, optional Channel placement, per-Bot Inbox Admissions, Trigger and Wake selection, provider account references, Service Grants, reply routing, ingress de-duplication, and delivery-outbox intent share its transaction. This is not a general relocation of all Bot state: Soul/Memory remain files, DSH execution remains in DSH Sessions, Session ownership and roster arrangement retain their DSH domains, settings retain their namespace, and credentials remain in the credentials service. Channel NDJSON becomes an explicit export rather than authority. The roster’s durable arrangement — section membership and names, section order, pins, and hidden navigation ids — moves host-side into the DSH storage domain botharness_roster on the composed json backend (version 1, layout: single): one global slot { pins: string[], hidden?: string[], sectionOrder: string[], topOrder?: TopOrderEntry[] } and a sections table keyed by host-generated id, { name: string, channelIds: string[] }. View preferences split: the sort mode — global default plus per-section modes — moves into the DSH settings namespace ui-bot-mode (schemastery schema with defaults, persisted host-side in settings.yaml, per profile and therefore cross-browser), because the owner wants it in DSH’s native General settings page as well as the sidebar header menu (one policy store, two surfaces; the DSH precedent is transcript-view / busy-enter / theme); the browser reads and writes it through ctx.settingsScope.bind({ namespace: 'ui-bot-mode' }) and refreshes on the allowlisted settings/document-updated event (writes are memory-only on non-loopback pages, DSH-wide behaviour — a recorded caveat). Only collapsed stays in browser localStorage. Realtime is a v1.1 mode: 'stream' remote method on the botharness namespace (host AsyncIterable, client connection.rpc.open) that pushes roster changes first and committed Messaging changes after, IM-style; domain/changed is in-process only and not forwardable (the api-remotes allowlist is static), so no client may depend on it.

Session ownership is a second mutable Host relationship with a separate named home: the DSH storage domain botharness_sessions, owned by the PersonaBot registry (ADR-0035). It stores Session id → PersonaBot/root-role/provenance and never writes Host Session ids into PersonaBot Soul files.

The split mirrors DSH’s own: ui-workspace keeps durable workspace order and membership host-side (storage domain), while groupBy/orderBy/expansion live in browser localStorage (dsh.workspace.view.v5). Each host domain follows DSH’s storage-domain conventions: domain and table names match ^[a-z][a-z0-9_]*$, defineDomain takes a positive integer version and a non-nullable global schema (layout: single is the json backend’s own setting), and the botharness_roster global slot carries the order and navigation arrays (pins, hidden, sectionOrder) exactly as the workspace precedent does. The client never touches host files or ctx.storage — anything host-owned or host-consumed is exposed through the botharness typert remote namespace as fine-grained bridge methods mirroring the action-shaped remote methods of ui-workspace: rosterGet, sectionCreate, sectionRename, sectionRemove, channelAssign, sectionReorder, topReorder, pinsSet, hiddenSet. Section ids are host-generated (sectionCreate returns them); the client never invents the id shape, and the uuid alphabet keeps table keys path-safe. Storage is an optional capability: the plugin loads through ctx.inject(['storageDomain'], cb) (or ctx.get), so a profile without a domain backend still loads the core plugin. Arrangement features degrade with a structured error rather than failing the mount; Bot Session creation and resume fail closed with storage-unavailable rather than guessing or silently losing ownership.

First load migrates once: if the host domain is empty and the legacy browser-local roster.json exists, sections/order/pins move into the domain and the sort-mode preference moves into the settings namespace; the old browser key is kept as a backup, and neither migration runs twice.

Considered Options

  • All files (extend roster.json-style files under $DSH_HOME) — rejected: the domain layer already gives schema validation, durable ordered writes, and a backend seam; hand-rolling files duplicates the storage hub and breaks the “domain form for durable host state” convention.
  • SQLite for every BotHarness state — rejected: the relational, fan-out-heavy Messaging aggregate and the policies that participate in its decisions need a shared transaction. PersonaBot Soul/Memory remain user-owned files, while DSH execution, roster, settings, credentials, and Session ownership retain their DSH-native homes.
  • All browser localStorage — rejected: durable arrangement is per-host data, not per-browser view state; localStorage cannot be exported, shared across browsers, or packaged by SoulSnapshot.
  • DSH settings namespace for sections/pins — rejected: settings are user preferences, not data; sections and membership stay durable arrangement in botharness_roster. The namespace is chosen for the sort-mode preference precisely because it is a user preference the owner wants on the native settings surface.
  • Keep sort mode in localStorage — rejected: it cannot appear as a native General settings row, does not persist per profile across browsers, and would keep the preference out of the host settings document that DSH already backs up and syncs.
  • settings.yaml (user-edited) — rejected: both arrangement and sort mode are written by the UI through actions, not hand-edited config, and gain no portability from YAML.
  • Persist the arrangement as files under bots/ — rejected: it is not PersonaBot identity/memory, and it would leak into packages/exports that should stay Soul-only.
  • Persist Session ids under bots/<personabot-id>/bot.json — rejected: Session ownership is Host runtime state, not Soul identity, and would leak into SoulSnapshot/export boundaries.
  • Push through domain/changed to the browser — impossible: the forwarded remote-event allowlist is a static first-party list; realtime must ride a mode: 'stream' remote method.

Consequences

  • @botharness/core gains an optional storageDomain inject and the botharness_roster domain; the nine roster bridge methods are unary. rosterGet is a read; sectionCreate and sectionRemove are composite writes (record plus global order) and channelAssign may write several section records (remove the old owner, insert the target), each returning only after every durable write; sectionRename, sectionReorder, topReorder, pinsSet, and hiddenSet are single writes. Without the capability, roster reads and writes both fail with storage-unavailable — rosterGet never projects a fake empty arrangement.
  • The accepted target adds a profile-scoped Messaging SQLite service and explicit Session ownership. ADR-0041 changes their physical home to botharness.db; therefore no new botharness_sessions domain is introduced.
  • The client drops sections/pins/order and sortMode from its localStorage config (kept as the one-time migration source) and keeps only collapsed; arrangement reads and writes go through the bridge, sort mode through the settings namespace, and the client re-reads rosterGet after each mutation (no optimistic state).
  • The settings namespace ui-bot-mode is per-profile host state: the sidebar header ... menu reads and writes the same scope as the new settings.general.item row, so both surfaces share one policy store; if the General row slot proves unreliable, the fallback is the cookbook-standard settings.plugin.item card.
  • Migration is one-way and conservative: run only when the host domain is empty and the legacy key exists; arrangement moves into botharness_roster and the sort preference into ui-bot-mode, the legacy browser key is kept as a backup, and neither migration runs twice.
  • sectionOrder is a global array (creation order then user order) — the workspace precedent — because the sections table has no implicit order.
  • RemoteErrorDetailsMap gains storage-unavailable so a profile without a storage backend fails roster reads and writes with a stable code.
  • Spec §2 gains the persistence table; ADR-0031’s update records the split (sections/pins/order host-side in the domain, sort preference in ui-bot-mode, collapsed local).
  • Tests cover the domain spec, the migration (empty/non-empty/legacy-absent/backup), the nine roster methods, and the client split.

Update (2026-09-20) — one BotHarness operational database

ADR-0041 supersedes the terminal physical placement in this ADR. #66 first shipped roster arrangement in botharness_roster and closed the temporary manual-order seam: positioned channelAssign calls now freeze the displayed order, then the client re-reads rosterGet; legacy roster.json is only a one-shot migration source, and sort mode remains in ui-bot-mode (#68).

#79 establishes the profile-scoped botharness.db owner without silently moving existing facts. #80 then imports the current roster domain once into an empty target and creates Session ownership in that same database; after successful activation, BotHarness stops creating its own storage domains and never dual-writes or falls back. Logical ownership, the fine-grained client bridge, the DSH-native ui-bot-mode preference, browser-local collapsed state, and post-commit stream remain unchanged.

Update (2026-09-20) — flat topOrder, retired bottom bucket (#56)

The current botharness_roster global slot gains optional topOrder: Array<{kind: 'section'|'channel', id}> while the domain remains version 1: a flat top-level order mixing section blocks and loose Channels. A loose entry holds no membership but does hold a position, so the bottom-fixed 未分组 bucket retires. Absent topOrder means a pre-flat domain; the client projects sections in sectionOrder followed by every unsectioned Channel, then writes through the eighth bridge method, topReorder. The Host maintains single ownership on assign/create/remove/reorder, and section reordering leaves loose slots fixed. ADR-0041/#80 must carry this logical shape and invariant into botharness.db during the planned one-way roster migration; this change introduces no dual writes.

Update (2026-09-21) — hidden navigation state (#137)

The same version-1 global slot gains optional hidden: string[]; absence is the backward-compatible empty list. hiddenSet is the ninth unary roster method. This field is durable presentation state only: it filters expanded roster, pin grid, search, and collapsed rail projections without rewriting pins, section records, or topOrder, so restore retains the Channel’s previous placement. It is not Channel deletion, archive, mute, Content Purge, or Soul data. ADR-0041/#80 must migrate this field with the rest of the roster state.

导航

输入以搜索…

↑↓ 导航↵ 选择Esc 关闭