Skip to content

plan: persona version history + rollback (#534 remaining) #726

Description

@btipling

Plan header

Field Value
Status IMPLEMENTED
Date 2026-07-10
Type single
Parent N/A
Source issue #534 — Versioned personas & skills: rollback to known-good when the agent miswrites
Branch plan/persona-versioning
Layers Vercel backend, DOM host (Settings UI)
Reusability impact none
Production mutate? yes — new schema migration 0015_user_persona_versions.sql (registered in the drizzle journal) + GHA db-migrate dispatch
Cloud ops path GHA db-migrate (existing db-migrate.yml workflow)
Living docs docs/personas.md, docs/harness-limits.md, AGENTS.md

Review notes (2026-07-10)

Reviewed under plan-review (mode=fix). Baseline grounded against live main
for every cited symbol (all verified). Verdict after edits: HANDOFF-READY.

# Severity Axis Issue landed
1 Major Cloud ops / correctness Migration 0015_user_persona_versions.sql must be registered in db/migrations/meta/_journal.json (idx 15) — drizzle-kit migrate only applies journal tags, exactly the #735 incident (0012–0014 existed as files yet were invisible → unavailable). The plan's Cloud ops row claimed db-migrate "auto-discovers SQL files", which is false. Locked: Implementation order step 2 + DoD + Cloud ops "After job" smoke now require the journal entry
2 Major Correctness / atomicity createUserPersona's non-default branch is a bare db.insert, not a db.transaction (only the isDefault branch wraps clear-then-set). The plan's "add a version INSERT in the same transaction" is unimplementable as written for the common path. Locked: wrap both branches' persona-insert + initial-version-insert in one db.transaction, mirroring createUserSkill (which wraps the entire insert+version)
3 Minor Correctness / gate Plan said routes use a "requireSettingsUser gate" — no such symbol exists. Skills version/rollback routes use requireUserId (from wire.ts, wrapping requireSessionUser); persona server-actions use the file-private requireSettingsSession(). Locked: the new /api/settings/personas version routes mirror the skills REST gate (requireUserId via requireSessionUser), not requireSettingsSession
4 Minor Testing Test #2 "pre-edit snapshot + new body (2 rows added)" is wrong for the normal create→edit flow: create stores the body as a version, so the first edit's pre-edit body is already stored → order-independent WHERE body = prevBody finds it → only the new body is inserted (1 row). The "2 rows" case only applies to a drifted/legacy row with no matching stored version. Locked: corrected rows
5 Nit Baseline PersonaForm.tsx is 365 lines, not 366 (corrected)
6 Nit Living docs docs/personas.md currently has no version-history section (84 lines) — plan correctly adds one; referenced explicitly

Not finding: PERSONA_VERSION_MAX = 100 is a NEW cap (generous default, no
existing cap changed) → no human-approval gate. Budget accounting: 16 KiB × 100
= 1.6 MiB per persona; with META_USER_PERSONAS_MAX = 50 the aggregate
append-only worst case is ~80 MiB/user — trivial Postgres, well within any wire
ceiling (persona bodies never ride a Function request body on this path; the
Settings REST routes return summaries/no-body, and single-version body GET rides
a plain Response far below the 4.5 MB Function bound). Confirmed no cap change.

Not finding: Layer placement is clean — backend (lib/tenancy/userPersonas.ts
store + db/) and DOM host (PersonaForm.tsx + /api/settings/personas routes)
only. No Wasm, no dual-chat, no secrets in client/Wasm (persona bodies are
published plaintext user content, no DEK — consistent with #534 and the shipped
skills versioning). Palette use in the UI mirrors the shipped SkillForm.tsx
panel (ember only for danger cap warnings, warm for accents) — no freehand hex.

Summary

Adds append-only version history + rollback to personas, mirroring the shipped skill-versioning pattern (Phase 1 #711 → PRs #713/#722). Every persona create / update_body captures a previous-known-good body snapshot in a new user_persona_versions table. Settings gains a per-persona "History" panel with Restore (rollback), Copy body, and View body — the same UI already live for skills in SkillForm.tsx. The meta_persona_* agent authoring tools keep running auto-confirm; version capture lives in the store service layer (no new tool surface).

Skills already ship complete version history + rollback (Phase 1). This plan adds the personas side — the only remaining gap in the #534 scope.

Goals

# Goal Success signal
1 Persona store records an append-only, bounded version history on every create / update_body (mirrors skill pattern) userPersonaVersions rows exist after create + each body edit
2 Settings shows a per-persona History surface with Restore (rollback) UI panel with version list, Copy body, View body, Restore button
3 Restore copies a prior version body into user_personas.body + inserts a new version row (rollback itself IS versioned) after Restore → body matches target version; new version row created
4 Caps added to Caps table; existing caps unchanged PERSONA_VERSION_MAX = 100 (new generous cap)
5 meta_persona_* agent tools capture history automatically (no new prompt, no new tool) update_body via agent → version row appears

Non-goals / out of scope

  • Not restoring a deleted persona from version history — version rows cascade-delete with the persona (same as skills), so deletion is final. The Versioned personas & skills: rollback to known-good when the agent miswrites #534 source mentions this as aspirational but it requires version-row survival (separate plan).
  • Not versioning name / slug / isDefault / recommendedSkillSlugs changes — only body edits create version rows (same scope as skills: create + update_body capture body-only snapshots). Name/rename/default changes are not body changes; the body is the user content worth rolling back.
  • Not changing the skill-versioning pattern — skills are done; this plan adds parity for personas.
  • Not a Settings "diff" view between versions — the skill-history panel already supports View body (raw text); no structural diff viewer.
  • Forbidden wiring: dual DOM chat · secrets in Wasm · laptop-only Production ops · phase/issue theater in product docs

Architectural decisions

Decision Options considered Choice Why
Schema for persona versions A) same user_skill_versions-style table: user_persona_versions(persona_id, body, label, created_at) with FK cascade; B) reuse user_skill_versions polymorphically A Mirrors the proven skill pattern; separate tables keep queries simple (no polymorphic union). Cascade delete is the shipped behavior for skills and the correct YAGNI choice — restoring deleted rows is a separate plan
Cap value PERSONA_VERSION_MAX A) 100 (same as skills); B) 50 (personas change less often) A 100 is generous and proven; personas hold ≤ 16 KiB (smaller than skills' 4 MiB), so 100 versions is trivial storage (~1.6 MiB/persona; ~80 MiB/user aggregate worst case at META_USER_PERSONAS_MAX=50 — trivial Postgres). Same cap = same mental model for operators
Version capture on update_body A) pre-edit snapshot (mirror skills); B) post-edit only A Proven pattern: capture the PRE-EDIT body as a version BEFORE writing the new body, via the skills' order-independent WHERE body = prevBody check + stamped −1 ms / stamped pair — so every version is a restorable known-good state and newest-first stays deterministic. The first version (from create) is the live body at create time, and rollback inserts the restored body as a new version (rollback itself IS versioned)
Settings UI placement A) PersonaForm.tsx version panel (same component as skills); B) separate page A The skill-history panel in SkillForm.tsx is compact, collapsible, and proven. Mirror it in PersonaForm.tsx directly — a version is body-only for personas, so the UI is a strict subset of the skill panel
API route shape A) mirror skills: GET /api/settings/personas/[id]/versions, GET .../versions/[versionId], POST .../rollback; B) single GET /api/settings/personas/[id]/history A Proven pattern; keeps the persona/skill API surfaces symmetric

Layer placement

Concern Layer Path(s) Rationale
Schema + migration Vercel backend db/schema.ts (+ Drizzle table), db/migrations/0015_user_persona_versions.sql + db/migrations/meta/_journal.json register idx 15 Same pattern as user_skill_versions (migration 0012); see review finding #1
Version capture on create/update Vercel backend lib/tenancy/userPersonas.tscreateUserPersona (wrap both branches in one tx), updateUserPersonaBody Store service layer — one owner for history capture; atomic with the persona write
List/get/rollback store functions Vercel backend lib/tenancy/userPersonas.tslistPersonaVersions, getPersonaVersion, rollbackPersona Mirrors listSkillVersions/getSkillVersion/rollbackSkill
API routes Vercel backend app/api/settings/personas/[id]/versions/route.ts, .../versions/[versionId]/route.ts, .../rollback/route.ts Mirrors app/api/settings/skills/[id]/versions/ (REST gate = requireUserId via requireSessionUser, not server-action requireSettingsSession)
Settings UI DOM host app/settings/personas/PersonaForm.tsx Mirrors SkillForm.tsx version panel
Cap definition Vercel backend lib/sessionCloudCaps.tsPERSONA_VERSION_MAX Mirrors SKILL_VERSION_MAX
DI factory Vercel backend lib/tenancy/userPersonas.tscreateUserPersonas Add listPersonaVersions/getPersonaVersion/rollbackPersona to factory

Current baseline (live code)

Claim Path / symbol Notes
Skills have version history + rollback shipped lib/tenancy/userSkills.ts (lines 690–913): listSkillVersions, getSkillVersion, rollbackSkill verified on main
Skills version table db/schema.ts:448–466 (userSkillVersions), migration 0012_user_skill_versions.sql verified; FK cascade, id uuid PK, skill_id, body, label, created_at
SKILL_VERSION_MAX = 100 lib/sessionCloudCaps.ts:127 verified; new generous cap
Skills API routes app/api/settings/skills/[id]/versions/route.ts, ...[versionId]/route.ts, .../rollback/route.ts verified on main; gate = requireUserId (wire.ts wrapping requireSessionUser)
Skills Settings UI app/settings/skills/SkillForm.tsx (lines 636–770): version list, Copy body, View body, Restore, cap warning verified on main
Persona store CRUD (no version capture) lib/tenancy/userPersonas.tscreateUserPersona, updateUserPersonaBody, deleteUserPersona verified; body-only plaintext, ≤ 16 KiB (PERSONA_BODY_MAX_BYTES = 16 KiB)
Persona DI factory lib/tenancy/userPersonas.ts:756–780createUserPersonas verified; returns 11 functions, no version functions
Persona Settings UI app/settings/personas/PersonaForm.tsx (365 lines) — edit form with body textarea, no version panel verified on main
Persona API route base No app/api/settings/personas/ directory exists yet — personas use server actions (app/settings/personas/actions.ts, gate = file-private requireSettingsSession()), not REST routes verified; will add REST routes for versions using the skills REST gate (requireUserId)
Skills API test patterns app/api/settings/skills/route.test.ts (lines 264–318) — version route tests with module mocking verified; pattern to follow
Existing GHA for DB migrate .github/workflows/db-migrate.yml (workflow_dispatch; guard confirm=migrate; dry_run; ubuntu-latest; npx drizzle-kit migrate) verified
Drizzle journal is the migration source of truth db/migrations/meta/_journal.json (tags 0000–0014 on main) verified; a migration file not registered in the journal is invisible to db-migrate (the #735 regression: 0012–0014 existed but were skipped → unavailable)

Caps table

Cap / ceiling Value Rationale Code location
PERSONA_VERSION_MAX 100 (NEW) Mirror of SKILL_VERSION_MAX; personas ≤ 16 KiB body = ~1.6 MiB/persona at cap (trivial); ~80 MiB/user aggregate at META_USER_PERSONAS_MAX=50 — still trivial Postgres. Generous ceiling for a NEW cap — no existing cap raised or lowered lib/sessionCloudCaps.ts

No existing cap is raised or lowered. The PERSONA_BODY_MAX_BYTES cap (16 KiB) and META_USER_PERSONAS_MAX (50) are reused unchanged.

Design

Schema

CREATE TABLE "user_persona_versions" (
    "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
    "persona_id" uuid NOT NULL REFERENCES "user_personas"("id") ON DELETE CASCADE,
    "body" text NOT NULL,
    "label" text DEFAULT '' NOT NULL,
    "created_at" timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX "user_persona_versions_persona_id_idx" ON "user_persona_versions" USING btree ("persona_id");

Mirrors user_skill_versions exactly — FK cascade, body text, optional label, indexed on FK column.

Journal registration (required — review finding #1)

After creating the 0015_user_persona_versions.sql file, register it in
db/migrations/meta/_journal.json at idx: 15 with tag: "0015_user_persona_versions", version: "7", breakpoints: true (mirror the
existing 0014 entry; or run npm run db:generate which appends the journal
entry from the schema change). Without the journal entry, db-migrate runs
green yet the table is never created
— the store's isUndefinedTable path
surfaces "unavailable", exactly the #735 regression.

Store changes (lib/tenancy/userPersonas.ts)

createUserPersonawrap BOTH branches (isDefault and normal) in a
db.transaction
so the persona INSERT and the initial version INSERT are
atomic (review finding #2; the current non-default branch is a bare db.insert
with no transaction — it must be wrapped, mirroring createUserSkill):

// inside one db.transaction covering the persona insert + version insert:
const [row] = await tx.insert(userPersonas).values({ ... }).returning({ id: userPersonas.id });
await tx.insert(userPersonaVersions).values({ personaId: row.id, body, label: '' });

updateUserPersonaBody — before the body UPDATE (all inside one
db.transaction):

  1. Count version rows; reject at PERSONA_VERSION_MAX (same gate as skills: fail BEFORE touching the live body)
  2. Read current body (ownership-gated)
  3. Pre-edit snapshot: order-independent WHERE body = prevBody check — insert the pre-edit body as a version row ONLY if it is not already stored (normally, after a create, it already is; this path fires for drifted/legacy rows), with stamped − 1 ms vs the new body's stamped timestamp
  4. UPDATE the body
  5. Insert the new body as a version row

New functions (mirror skills; return UserPersonasResult<...>, owning the
same error-code contract as userPersonas):

  • listPersonaVersions(userId, personaId)UserPersonasResult<PersonaVersionSummary[]> — summaries (id, label, createdAt), ownership-gated, newest-first, ≤ PERSONA_VERSION_MAX
  • getPersonaVersion(userId, personaId, versionId)UserPersonasResult<PersonaVersion | null> — single version with body, ownership-gated, no-existence-leak (null)
  • rollbackPersona(userId, personaId, versionId)UserPersonasResult<{ id }> — copies version body into live row + inserts new version (atomic), version-count gate before write (rollback inserts a version, so it does NOT free a slot — same cap semantics as skills)

API routes

  • GET /api/settings/personas/[id]/versions — list version summaries (no body)
  • GET /api/settings/personas/[id]/versions/[versionId] — single version body (raw text, un-escaped, like skills)
  • POST /api/settings/personas/[id]/rollback{ versionId } → rollback

All routes: REST gate mirroring skillsrequireUserId (a personas
wire.ts helper wrapping requireSessionUser, NOT the server-action
requireSettingsSession) → DI services.userPersonas.* → JSON response.
Pattern matches app/api/settings/skills/[id]/versions/* exactly (review
finding #3).

Settings UI (PersonaForm.tsx)

Add a collapsible "Version history" section below the body textarea, matching SkillForm.tsx:636–770:

  • Toggle: "Version history" heading + Show button, then Loading…/list
  • Cap warnings: "At the 100-version cap" (ember) / "99 of 100 — next Restore is the last one-way slot" (warm)
  • Empty state: "No versions yet — the first edit creates version history."
  • Version list (max-height 240px, scroll): each row shows now (newest) / vN label, timestamp, Copy body button, View body button (expand inline in <pre>), Restore button (disabled at cap or while pending)
  • Restore calls POST /api/settings/personas/[id]/rollback with versionId, then reloads the page (same as skills: the live body changed)

Edge cases:

  • Empty version list (pre-edit personas before migration) → "No versions yet…" message
  • Cap reached → Restore disabled + ember warning
  • Network error → inline error message, retry on next click
  • Body view loading → "Loading body…" placeholder
  • Body view error → ember error message

DI factory update

Add listPersonaVersions, getPersonaVersion, rollbackPersona to createUserPersonas() factory — same closure pattern as the existing 11 functions.

Cloud ops path

Item Lock
Primary operator surface GitHub Actions → db-migrate → Run workflow (confirm=migrate)
Workflow to extend .github/workflows/db-migrate.yml — existing; no workflow changes needed (it already runs npx drizzle-kit migrate)
What the job runs npx drizzle-kit migrate (ephemeral drizzle-kit@0.31.10) — reads db/migrations/meta/_journal.json; the new 0015 migration MUST be journal-registered in the same PR, or the workflow runs green yet never creates the table (the #735 failure mode — db-migrate does NOT auto-discover loose .sql files)
Secrets (names only) DATABASE_URL (already configured)
Guards confirm string = migrate (misclick guard), dry_run optional, ubuntu-latest, no self-hosted
After job Vercel auto-deploys on main push; smoke: open Settings → Personas → edit body → verify version row appears and confirm the migration actually applied (0015_user_persona_versions exists; if the panel reports "unavailable", the journal entry is missing — re-check _journal.json)
Explicit non-paths not seed; not backfill; not personal laptop npm

Living docs plan

Surface Change Notes
docs/personas.md Add version history + rollback section (currently absent — the doc is 84 lines); note body edits capture versions; Restore recovers to known-good timeless; mirror docs/skills.md §Version history & rollback
docs/harness-limits.md Add persona version cap row under existing skill version caps row PERSONA_VERSION_MAX = 100
AGENTS.md Add userPersonaVersions to the Drizzle schema/migration inventory under the persona section match existing persona/skill entries
README.md N/A — no front-door change
SECURITY.md N/A — no new secrets or trust boundary (plaintext user content, same as personas/skills themselves)
.env.example N/A — no new env var

Implementation order

  1. db/schema.ts — add userPersonaVersions Drizzle table definition (mirror userSkillVersions)
  2. db/migrations/0015_user_persona_versions.sql — new migration file + register tag 0015_user_persona_versions at idx 15 in db/migrations/meta/_journal.json (or npm run db:generate to produce both) — review finding 2.1 Provision DigitalOcean droplet for builds #1
  3. lib/sessionCloudCaps.ts — add PERSONA_VERSION_MAX = 100 (NEW cap; no existing cap changed)
  4. lib/tenancy/userPersonas.ts — wrap both branches of createUserPersona in a db.transaction + add the initial version insert; add pre-edit-snapshot version capture to updateUserPersonaBody; add listPersonaVersions, getPersonaVersion, rollbackPersona; update createUserPersonas factory — review finding 2.2 Install and register GitHub Actions self-hosted runner #2
  5. app/api/settings/personas/[id]/versions/route.ts + a personas wire.ts REST gate (requireUserId) — list endpoint — review finding 1.1 Create GitHub repo invincible #3
  6. app/api/settings/personas/[id]/versions/[versionId]/route.ts — get endpoint
  7. app/api/settings/personas/[id]/rollback/route.ts — rollback endpoint
  8. app/settings/personas/PersonaForm.tsx — add version history panel
  9. Tests: store tests (version capture, rollback, cap), route tests (list/get/rollback endpoints)
  10. Build gates + docs in the same PR

Testing

# Case Layer Type Command / method
1 createUserPersona inserts an initial version row atomically (body matches; normal + isDefault branches) backend unit lib/tenancy/userPersonas.test.ts
2 updateUserPersonaBody after a normal create captures ONLY the new body (1 row — pre-edit body already stored from create) backend unit lib/tenancy/userPersonas.test.ts
3 updateUserPersonaBody on a drifted/legacy row (live body not stored as a version) captures BOTH pre-edit snapshot + new body (2 rows, newest-first deterministic with stamped −1 ms pair) backend unit lib/tenancy/userPersonas.test.ts
4 updateUserPersonaBody at version cap → rejected, body unchanged backend unit lib/tenancy/userPersonas.test.ts
5 listPersonaVersions returns newest-first summaries, no body, ownership-gated (non-owner → []) backend unit lib/tenancy/userPersonas.test.ts
6 getPersonaVersion returns body by version id, ownership-gated, no-existence-leak (null) backend unit lib/tenancy/userPersonas.test.ts
7 rollbackPersona copies version body → live row + inserts new version row (atomic) backend unit lib/tenancy/userPersonas.test.ts
8 rollbackPersona at version cap → rejected, body unchanged backend unit lib/tenancy/userPersonas.test.ts
9 GET /api/settings/personas/[id]/versions → version summaries (401 unauth / happy) backend integration app/api/settings/personas/route.test.ts
10 GET .../versions/[versionId] → body (raw text); non-owner/missing → 404 backend integration route test
11 POST .../rollback → success + version row; cap → 400 backend integration route test
12 Settings UI: version panel renders list, Copy body, View body, Restore (happy path, empty, cap, error) DOM operator / unit browser smoke or component test
13 Build gates repo gate npm run typecheck, vitest run (full, di-gate via npm test), npm run build

Minimum locked: #1#11 (store + route tests) + typecheck + full vitest + build (agent workspace/CI). No Zig/native change → build-harness does not apply; #735 taught that the journal-registered migration + a real db-migrate run (dry_run then confirm=migrate) plus the Settings smoke is the authoritative DB gate.

Definition of done

  • user_persona_versions table (schema + migration) exists and is registered at idx 15 in db/migrations/meta/_journal.json (review finding 2.1 Provision DigitalOcean droplet for builds #1)
  • createUserPersona (both branches) + updateUserPersonaBody capture version rows atomically (review finding 2.2 Install and register GitHub Actions self-hosted runner #2)
  • listPersonaVersions, getPersonaVersion, rollbackPersona in store + DI factory, returning UserPersonasResult<...>
  • API routes (versions, versions/[versionId], rollback) present under /api/settings/personas, gated by the REST requireUserId (wire.ts) — review finding 1.1 Create GitHub repo invincible #3
  • Settings PersonaForm.tsx shows version history panel with Restore
  • Cap PERSONA_VERSION_MAX = 100 in lib/sessionCloudCaps.ts + Caps table (NEW cap; no existing cap raised/lowered)
  • Tests green: npm run typecheck, vitest run (full), npm run build (agent workspace/CI)
  • No dual-chat regression
  • Cloud ops: GHA db-migrate primary path used; _journal.json registered so the job actually applies 0015 (smoke: Settings → Personas → edit body → version row appears)
  • Living docs: docs/personas.md (new §Version history & rollback) + docs/harness-limits.md + AGENTS.md updated

Risks & mitigations

Risk Mitigation
Migration file exists but is NOT journal-registered → db-migrate runs green yet the table is never created, surfacing "unavailable" (the exact #735 regression) Register 0015_user_persona_versions at journal idx 15 in the same PR (Implementation order step 2 + DoD + Cloud ops smoke); smoke verifies the panel actually works after migrate
createUserPersona's non-default path is not currently transactional → version insert could be non-atomic with the persona insert Wrap both branches in one db.transaction (mirror createUserSkill), review finding #2
Version capture on update_body diverges from skill pattern Explicitly mirror the skill code path: order-independent WHERE body = prevBody check + stamped −1 ms pair, inside one transaction
Normal post-create edit "double-inserts" a redundant snapshot (2 rows) worth a cap slot The order-independent check stores the pre-edit body only when it is NOT already a version row (after create it already is) — this is covered by test #2 vs #3 split
Persona body cap (16 KiB) × 100 versions = 1.6 MiB/persona; ~80 MiB/user aggregate at 50 personas — trivial PERSONA_VERSION_MAX = 100 is generous; no risk of storage blowout; no cap changed
Persona delete cascade-deletes versions (same as skills) Documented as intentional: restoring deleted rows is a separate plan (non-goal)
PersonaForm.tsx grows large with version panel Versions UI is ~135 lines in SkillForm.tsx — same scale; extract to a shared component if needed
No pre-existing API route pattern for personas (server actions only) Follow the REST pattern from skills API routes exactly (same requireUserId/requireSessionUser gate, same response shape) — NOT the server-action requireSettingsSession gate
Laptop-only migration Cloud ops path is GHA db-migrate (existing workflow) — never laptop npm

Open questions

None (in-scope engineering choices are locked above). Restoring deleted personas is explicitly out of scope (non-goal).

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions