Repository navigation
fix(core): one malformed persona_bots row takes down the whole Host (Invalid PersonaBot Registry record) #3213
Description
Summary
A single row in persona_bots whose modelPlan does not match isPersonaBotModelPlan makes createPersonaBotRegistry() throw during apply, so the whole @botharness/core plugin goes failed. The registry, memory, inbox and schedules of every PersonaBot go down together until someone repairs the row by hand.
decode() already has a sanitizer for exactly this class of problem — it deletes appearance, banner and primaryColor when they do not validate — but modelPlan / modelPlanRevision were never added to that list, so they reach the throw at the bottom.
Impact
Observed on v1.6.0 (2026-10-30, after a Host restart):
@botharness/corefiber failed;botharness-client/botharness-browser/botharness-computerunusable as a consequence.- Error surface was a single line:
Invalid PersonaBot Registry record. - Recovery required stopping the Host, editing
botharness.dbby hand, and re-runningentry.init().
This is not a version regression: packages/core/src/bots/registry.ts:309 (throw new Error('Invalid PersonaBot Registry record')) is byte-identical in 1.5.2 and 1.6.0, so downgrading does not help.
Root cause
packages/core/src/bots/registry.ts — decode() (lines 286–311):
const decode = (text: string, slug: string): PersonaBotRecord => {
const parsed: unknown = JSON.parse(text);
if (… 'appearance' in parsed && !isUsableAvatarAppearance(…))
delete (parsed as { appearance?: unknown }).appearance;
if (… 'banner' in parsed && !isBotBanner(parsed.banner))
delete (parsed as { banner?: unknown }).banner;
if (… 'primaryColor' in parsed && !isBotPrimaryColor(parsed.primaryColor))
delete (parsed as { primaryColor?: unknown }).primaryColor;
// ← no sanitizer for modelPlan / modelPlanRevision
if (!isPersonaBotRecord(parsed, slug)) throw new Error('Invalid PersonaBot Registry record');
return recordSnapshot(parsed);
};and list() (lines 338–345) maps decode over every row with no per-row isolation:
const list = (): PersonaBotRecord[] =>
port.read((database) =>
database.prepare('SELECT slug, body FROM persona_bots').all()
.map((row) => decode(String(row['body']), String(row['slug'])))
.sort((left, right) => left.slug.localeCompare(right.slug)));createPersonaBotRegistry() calls list() during construction, so the throw propagates out of apply and the fiber fails. One bad row, whole Host down.
Reproduction
Write a row whose modelPlan is a bare route (a shape an agent or a hand-edit can plausibly produce) plus any integer modelPlanRevision:
{
"slug": "bot-<id>",
"displayName": "…",
"workspaces": [],
"createdAt": "2026-10-29T…Z",
"modelPlan": { "provider": "some-provider", "model": "some-model", "reasoningEffort": "low" },
"modelPlanRevision": 139
}isPersonaBotRecord() (packages/core/src/bots/persona-bot.ts:222) then fails on modelPlan, decode() throws, list() throws, apply throws.
Note the write path never had a chance to reject it either: the only sanctioned writer is the registry's own write() (transactions tagged ['bot-registry']), but any process holding the SQLite file can append a row, and the schema only enforces CHECK (json_valid(body)) — validity of shape is checked on read, never on write.
Proposed patch
Minimal, and it follows the three sanitizers already in decode() rather than inventing a new mechanism. Dropping an unusable modelPlan puts that bot back on host-default routing, which is already the documented meaning of modelPlan === undefined (record.modelPlan === void 0 && record.model === void 0 ? 'host-default' : 'set'), so the blast radius is one bot's routing instead of the whole Host.
Verified to apply on main at 295405d (git apply --check exit 0, result byte-identical to the intended file):
diff --git a/packages/core/src/bots/registry.ts b/packages/core/src/bots/registry.ts
index 295405d..a9129c7 100644
--- a/packages/core/src/bots/registry.ts
+++ b/packages/core/src/bots/registry.ts
@@ -56,11 +56,12 @@ import {
} from '../memory/soul.js';
import type { TelemetryEventName } from '../telemetry/events.js';
import type { TelemetryCapture } from '../telemetry/service.js';
-import type {
- AssignmentModelOption,
- ModelPreset,
- ModelRoute,
- PersonaBotModelPlan,
+import {
+ isPersonaBotModelPlan,
+ type AssignmentModelOption,
+ type ModelPreset,
+ type ModelRoute,
+ type PersonaBotModelPlan,
} from '../models/presets.js';
import { parsePromptLimitValues, type PromptLimitValues } from '../prompts/prompt-limits.js';
import { parsePromptPartOverrides, type PromptPartOverrides } from '../prompts/prompt-presets.js';
@@ -306,6 +307,21 @@ export function createPersonaBotRegistry(options: PersonaBotRegistryOptions): Pe
!isBotPrimaryColor(parsed.primaryColor)
)
delete (parsed as { primaryColor?: unknown }).primaryColor;
+ if (
+ typeof parsed === 'object' &&
+ parsed !== null &&
+ 'modelPlan' in parsed &&
+ !isPersonaBotModelPlan(parsed.modelPlan)
+ )
+ delete (parsed as { modelPlan?: unknown }).modelPlan;
+ if (
+ typeof parsed === 'object' &&
+ parsed !== null &&
+ 'modelPlanRevision' in parsed &&
+ (!Number.isSafeInteger(parsed.modelPlanRevision) ||
+ (parsed.modelPlanRevision as number) < 0)
+ )
+ delete (parsed as { modelPlanRevision?: unknown }).modelPlanRevision;
if (!isPersonaBotRecord(parsed, slug)) throw new Error('Invalid PersonaBot Registry record');
return recordSnapshot(parsed);
};isPersonaBotModelPlan already exists at packages/core/src/models/presets.ts:179 and is what isPersonaBotRecord uses for the same field, so no new predicate is introduced. modelPlanRevision gets the same treatment because persona-bot.ts:216-221 rejects a non-safe-integer / negative revision, which has the identical failure mode.
Deliberately left out of this patch
list() still throws on any row that fails decode after sanitizing (e.g. a missing displayName). I would suggest handling that separately, because skipping invalid rows silently hides data loss — it needs a diagnostics channel (a logged/surfaced list of skipped slugs) rather than a bare try { } catch { continue }. Happy to send that as a second PR if you want the direction.
Environment
@botharness/core1.6.0 (also reproduces on 1.5.2), Windows, Node 24, local SQLite 3.53.1.- Data check on the affected Host after hand-repair: 7 rows in
persona_bots, all 7 passisPersonaBotRecord, 1 row inmodel_presetspassesisModelPreset. - Search for this error string on GitHub returns 0 results, so this is presumably the first report.
Activity
hkhkhkhh2211 commentedon Oct 30, 2026
Follow-up after re-reading the module — I want to correct the emphasis in the original report.
The
decode()sanitizer above fixes the blast radius. The actual root cause is one layer up:write()persists without ever validating the record it is about to store.Validation exists only on the read side (
decode→isPersonaBotRecord) and, for some fields, in the domain helpers. The table itself only enforces shape-free validity:So the invariant "every persisted body satisfies
isPersonaBotRecord" is enforced nowhere at the point of persistence.What makes this worth fixing at the write site rather than only at the read site:
setModelPlan()builds the object itself (revision + 1,sourcePresetId: "",sourcePresetName: "",orchestrator,assignmentDefault,assignmentModels,appliedAt: now()), and every other mutator goes throughrecordSnapshot. If those writers are the only writers, the invariant holds by construction.botharness.dbdirectly and appended a row. The schema cannot express the invariant (json_validonly checks JSON syntax), so the only place left that can referee iswrite()itself.Suggested addition (same style as the existing guards, no new predicate):
With this in place the
decode()sanitizer stops being the primary defence and becomes what it should be: a repair path for databases that were already damaged before the guard existed (and for the import path, which writes rows it decoded from disk rather than from memory).If you would rather keep the write path unchanged, the
decode()patch alone is still worth taking — it just means the same class of damage has to be discovered by a Host restart instead of being rejected at the source.DoodleBears commentedon Oct 31, 2026
Agent work claim
codex/local/01a191d8-3112-7872-985c-45234311ec2e(not a DSH Session)t3/isolate-invalid-model-planRead the issue and all comments; no existing active claim or open PR for #3213 found. Correction to the follow-up: validation inside registry.write() cannot reject a different process's direct SQLite writes. Historical/direct-SQL recovery must therefore be enforced at the existing registry decode boundary; a sanctioned-write guard can only be defence in depth.
DoodleBears commentedon Oct 31, 2026
Agent work outcome
codex/local/01a191d8-3112-7872-985c-45234311ec2e(not a DSH Session)t3/isolate-invalid-model-planReproduction and corrected causal boundary
All fixtures used new temporary databases, never an existing user's database. The initial unmodified implementation failed twice at
createCore -> createPersonaBotRegistry -> list -> decodewith the exact errorInvalid PersonaBot Registry record, after closing the owner and changing a row through a separatenode:sqliteconnection. One case used a bare-routemodelPlanplus revision 139; the other retained a valid plan but used revision -1.The minimized fixture needs only one SQL-inserted identity and one damaged optional field, with no legacy bot.json. Removing the damaged field makes the control pass. The actual red-capable command was:
The minimized baseline was 4 failed / 1 passed, all failures carrying the exact registry exception (9.06 seconds). Adding only a write guard left all four failures unchanged (9.05 seconds): direct SQL demonstrably bypasses registry.write(). Recovering only modelPlan left two revision failures; recovering both fields made the original five cases pass. The same command now passes all 16 focused cases after expanding malformed-value coverage.
Change
Evidence and limits
core.autocrlf=false; no repository/global Git configuration was changed.pnpm lint, touched-file formatting check,pnpm changelog:check,git diff --checkandpnpm buildpassed. Globaltsc --noEmitis blocked by an untouched error atpackages/client/test/mentions.test.ts:92(TS2353, botSlug).0.2.1-rc.2Host with two externally SQL-damaged Bots plus a healthy Bot. Launcher health and doctor returned authenticated API success;botharness/listreturned all three. A read-only canonical SQL query during that run confirmed the bad plan/revision were still stored, proving successful startup was not a manual database repair. That task-owned Host was stopped and its temporary home deleted.unobserved(observe exit 2); UI rendering, live model calls, Inbox/Schedule execution and full unpatched-Host failure were not verified.No other active claim was present at handoff. The task's
agent:activelabel can be removed while leaving this issue open for review and the stated gap.DoodleBears commentedon Oct 31, 2026
Agent work claim — integration
codex/local/01a191cc-31b6-7271-bf8b-6ed6689e15d0(not a DSH Session)Invalid PersonaBot Registry record) #3213 handoff; rerun the failing regression on the integration baseline before applying the fix, then rerun focused tests. Preserve unrelated session changes.t3/07f80e4f; integration checkout:main(no commit or publication requested).DoodleBears commentedon Oct 31, 2026
Agent work outcome — integrated locally, not released
codex/local/01a191cc-31b6-7271-bf8b-6ed6689e15d0; state: handed-off pending overall delivery.t3/07f80e4fpatch into the integration checkout; no commit, PR, push, merge or release.pnpm exec vitest run packages/core/test/registry-database.test.ts -t "minimal direct-SQL|starts Core after direct-SQL"failed 15 cases with the originalInvalid PersonaBot Registry recorderror; the healthy control passed. Applied the production fix, reran the identical command: 16 passed.DoodleBears commentedon Oct 31, 2026
Agent work claim — PR delivery
codex/local/01a191cc-31b6-7271-bf8b-6ed6689e15d0(not a DSH Session)Invalid PersonaBot Registry record) #3213 repair; preserve the isolated branch, update against current main, rerun regression and provide review evidence.t3/isolate-invalid-model-planThe PR will use Closes #3213; no merge or release is authorized by this step. Other corruption outside optional model settings remains explicitly out of scope.
DoodleBears commentedon Oct 31, 2026
Agent work outcome — standalone PR ready for review
codex/local/01a191cc-31b6-7271-bf8b-6ed6689e15d0; state: handed-off for review.38ac4b6976ec9982dd9c38f07fd281330c41f271; isolated one-commit, three-file fix rebased onto current main.Invalid PersonaBot Registry record) #3213, so this issue remains open until merge. No merge or release performed.DoodleBears commentedon Oct 31, 2026
PR #3239 CI update: affected tests, core/static checks, docs build and isolated Host e2e smoke passed. The aggregate gate is blocked by Expo Doctor requiring newer patch versions in unchanged mobile dependencies; details and exact job link are in the PR. No unrelated dependency upgrade or merge bypass was made. Issue remains open pending merge.