You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.sqlmust 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)
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-independentWHERE 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
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
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
CREATETABLE "user_persona_versions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"persona_id" uuid NOT NULLREFERENCES"user_personas"("id") ON DELETE CASCADE,
"body"textNOT NULL,
"label"text DEFAULT ''NOT NULL,
"created_at"timestamp with time zone DEFAULT now() NOT NULL
);
CREATEINDEX "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.
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)
createUserPersona — wrap 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]=awaittx.insert(userPersonas).values({ ... }).returning({id: userPersonas.id});awaittx.insert(userPersonaVersions).values({personaId: row.id, body,label: ''});
updateUserPersonaBody — before the body UPDATE (all inside one db.transaction):
Count version rows; reject at PERSONA_VERSION_MAX (same gate as skills: fail BEFORE touching the live body)
Read current body (ownership-gated)
Pre-edit snapshot: order-independentWHERE 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
UPDATE the body
Insert the new body as a version row
New functions (mirror skills; return UserPersonasResult<...>, owning the
same error-code contract as userPersonas):
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 skills → requireUserId (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)
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)
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)
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
lib/sessionCloudCaps.ts — add PERSONA_VERSION_MAX = 100 (NEW cap; no existing cap changed)
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
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
app/api/settings/personas/[id]/versions/[versionId]/route.ts — get endpoint
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
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.
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).
Plan header
0015_user_persona_versions.sql(registered in the drizzle journal) + GHAdb-migratedispatchdb-migrate(existingdb-migrate.ymlworkflow)docs/personas.md,docs/harness-limits.md,AGENTS.mdReview notes (2026-07-10)
Reviewed under plan-review (mode=fix). Baseline grounded against live
mainfor every cited symbol (all verified). Verdict after edits: HANDOFF-READY.
0015_user_persona_versions.sqlmust be registered indb/migrations/meta/_journal.json(idx 15) —drizzle-kit migrateonly 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 entrycreateUserPersona's non-default branch is a baredb.insert, not adb.transaction(only theisDefaultbranch 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 onedb.transaction, mirroringcreateUserSkill(which wraps the entire insert+version)requireSettingsUsergate" — no such symbol exists. Skills version/rollback routes userequireUserId(fromwire.ts, wrappingrequireSessionUser); persona server-actions use the file-privaterequireSettingsSession(). Locked: the new/api/settings/personasversion routes mirror the skills REST gate (requireUserIdviarequireSessionUser), notrequireSettingsSessionWHERE body = prevBodyfinds 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 rowsPersonaForm.tsxis 365 lines, not 366 (corrected)docs/personas.mdcurrently has no version-history section (84 lines) — plan correctly adds one; referenced explicitlyNot finding:
PERSONA_VERSION_MAX = 100is a NEW cap (generous default, noexisting cap changed) → no human-approval gate. Budget accounting: 16 KiB × 100
= 1.6 MiB per persona; with
META_USER_PERSONAS_MAX= 50 the aggregateappend-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.tsstore +
db/) and DOM host (PersonaForm.tsx+/api/settings/personasroutes)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.tsxpanel (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_bodycaptures a previous-known-good body snapshot in a newuser_persona_versionstable. Settings gains a per-persona "History" panel with Restore (rollback), Copy body, and View body — the same UI already live for skills inSkillForm.tsx. Themeta_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
create/update_body(mirrors skill pattern)userPersonaVersionsrows exist after create + each body edituser_personas.body+ inserts a new version row (rollback itself IS versioned)PERSONA_VERSION_MAX= 100 (new generous cap)meta_persona_*agent tools capture history automatically (no new prompt, no new tool)Non-goals / out of scope
name/slug/isDefault/recommendedSkillSlugschanges — only body edits create version rows (same scope as skills:create+update_bodycapture body-only snapshots). Name/rename/default changes are not body changes; the body is the user content worth rolling back.Architectural decisions
user_skill_versions-style table:user_persona_versions(persona_id, body, label, created_at)with FK cascade; B) reuseuser_skill_versionspolymorphicallyPERSONA_VERSION_MAXMETA_USER_PERSONAS_MAX=50 — trivial Postgres). Same cap = same mental model for operatorsupdate_bodyWHERE body = prevBodycheck + stamped −1 ms / stamped pair — so every version is a restorable known-good state and newest-first stays deterministic. The first version (fromcreate) is the live body at create time, and rollback inserts the restored body as a new version (rollback itself IS versioned)PersonaForm.tsxversion panel (same component as skills); B) separate pageSkillForm.tsxis compact, collapsible, and proven. Mirror it inPersonaForm.tsxdirectly — a version is body-only for personas, so the UI is a strict subset of the skill panelGET /api/settings/personas/[id]/versions,GET .../versions/[versionId],POST .../rollback; B) singleGET /api/settings/personas/[id]/historyLayer placement
db/schema.ts(+ Drizzle table),db/migrations/0015_user_persona_versions.sql+db/migrations/meta/_journal.jsonregister idx 15user_skill_versions(migration 0012); see review finding #1lib/tenancy/userPersonas.ts→createUserPersona(wrap both branches in one tx),updateUserPersonaBodylib/tenancy/userPersonas.ts→listPersonaVersions,getPersonaVersion,rollbackPersonalistSkillVersions/getSkillVersion/rollbackSkillapp/api/settings/personas/[id]/versions/route.ts,.../versions/[versionId]/route.ts,.../rollback/route.tsapp/api/settings/skills/[id]/versions/(REST gate =requireUserIdviarequireSessionUser, not server-actionrequireSettingsSession)app/settings/personas/PersonaForm.tsxSkillForm.tsxversion panellib/sessionCloudCaps.ts→PERSONA_VERSION_MAXSKILL_VERSION_MAXlib/tenancy/userPersonas.ts→createUserPersonaslistPersonaVersions/getPersonaVersion/rollbackPersonato factoryCurrent baseline (live code)
lib/tenancy/userSkills.ts(lines 690–913):listSkillVersions,getSkillVersion,rollbackSkillmaindb/schema.ts:448–466(userSkillVersions), migration0012_user_skill_versions.sqliduuid PK,skill_id,body,label,created_atSKILL_VERSION_MAX= 100lib/sessionCloudCaps.ts:127app/api/settings/skills/[id]/versions/route.ts,...[versionId]/route.ts,.../rollback/route.tsmain; gate =requireUserId(wire.tswrappingrequireSessionUser)app/settings/skills/SkillForm.tsx(lines 636–770): version list, Copy body, View body, Restore, cap warningmainlib/tenancy/userPersonas.ts—createUserPersona,updateUserPersonaBody,deleteUserPersonaPERSONA_BODY_MAX_BYTES= 16 KiB)lib/tenancy/userPersonas.ts:756–780→createUserPersonasapp/settings/personas/PersonaForm.tsx(365 lines) — edit form with body textarea, no version panelmainapp/api/settings/personas/directory exists yet — personas use server actions (app/settings/personas/actions.ts, gate = file-privaterequireSettingsSession()), not REST routesrequireUserId)app/api/settings/skills/route.test.ts(lines 264–318) — version route tests with module mocking.github/workflows/db-migrate.yml(workflow_dispatch; guardconfirm=migrate; dry_run; ubuntu-latest;npx drizzle-kit migrate)db/migrations/meta/_journal.json(tags 0000–0014 on main)unavailable)Caps table
PERSONA_VERSION_MAXSKILL_VERSION_MAX; personas ≤ 16 KiB body = ~1.6 MiB/persona at cap (trivial); ~80 MiB/user aggregate atMETA_USER_PERSONAS_MAX=50 — still trivial Postgres. Generous ceiling for a NEW cap — no existing cap raised or loweredlib/sessionCloudCaps.tsNo existing cap is raised or lowered. The
PERSONA_BODY_MAX_BYTEScap (16 KiB) andMETA_USER_PERSONAS_MAX(50) are reused unchanged.Design
Schema
Mirrors
user_skill_versionsexactly — FK cascade,bodytext, optionallabel, indexed on FK column.Journal registration (required — review finding #1)
After creating the
0015_user_persona_versions.sqlfile, register it indb/migrations/meta/_journal.jsonatidx: 15withtag: "0015_user_persona_versions",version: "7",breakpoints: true(mirror theexisting 0014 entry; or run
npm run db:generatewhich appends the journalentry from the schema change). Without the journal entry,
db-migraterunsgreen yet the table is never created — the store's
isUndefinedTablepathsurfaces "unavailable", exactly the #735 regression.
Store changes (
lib/tenancy/userPersonas.ts)createUserPersona— wrap BOTH branches (isDefault and normal) in adb.transactionso the persona INSERT and the initial version INSERT areatomic (review finding #2; the current non-default branch is a bare
db.insertwith no transaction — it must be wrapped, mirroring
createUserSkill):updateUserPersonaBody— before the body UPDATE (all inside onedb.transaction):PERSONA_VERSION_MAX(same gate as skills: fail BEFORE touching the live body)WHERE body = prevBodycheck — 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 timestampNew functions (mirror skills; return
UserPersonasResult<...>, owning thesame error-code contract as
userPersonas):listPersonaVersions(userId, personaId)→UserPersonasResult<PersonaVersionSummary[]>— summaries (id, label, createdAt), ownership-gated, newest-first, ≤PERSONA_VERSION_MAXgetPersonaVersion(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 }→ rollbackAll routes: REST gate mirroring skills →
requireUserId(a personaswire.tshelper wrappingrequireSessionUser, NOT the server-actionrequireSettingsSession) → DIservices.userPersonas.*→ JSON response.Pattern matches
app/api/settings/skills/[id]/versions/*exactly (reviewfinding #3).
Settings UI (
PersonaForm.tsx)Add a collapsible "Version history" section below the body textarea, matching
SkillForm.tsx:636–770:now(newest) /vNlabel, timestamp, Copy body button, View body button (expand inline in<pre>), Restore button (disabled at cap or while pending)POST /api/settings/personas/[id]/rollbackwithversionId, then reloads the page (same as skills: the live body changed)Edge cases:
DI factory update
Add
listPersonaVersions,getPersonaVersion,rollbackPersonatocreateUserPersonas()factory — same closure pattern as the existing 11 functions.Cloud ops path
db-migrate→ Run workflow (confirm=migrate).github/workflows/db-migrate.yml— existing; no workflow changes needed (it already runsnpx drizzle-kit migrate)npx drizzle-kit migrate(ephemeral drizzle-kit@0.31.10) — readsdb/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.sqlfiles)DATABASE_URL(already configured)migrate(misclick guard), dry_run optional, ubuntu-latest, no self-hostedmainpush; smoke: open Settings → Personas → edit body → verify version row appears and confirm the migration actually applied (0015_user_persona_versionsexists; if the panel reports "unavailable", the journal entry is missing — re-check_journal.json)Living docs plan
docs/personas.mddocs/skills.md§Version history & rollbackdocs/harness-limits.mdPERSONA_VERSION_MAX= 100AGENTS.mduserPersonaVersionsto the Drizzle schema/migration inventory under the persona sectionREADME.mdSECURITY.md.env.exampleImplementation order
db/schema.ts— adduserPersonaVersionsDrizzle table definition (mirroruserSkillVersions)db/migrations/0015_user_persona_versions.sql— new migration file + register tag0015_user_persona_versionsat idx 15 indb/migrations/meta/_journal.json(ornpm run db:generateto produce both) — review finding 2.1 Provision DigitalOcean droplet for builds #1lib/sessionCloudCaps.ts— addPERSONA_VERSION_MAX = 100(NEW cap; no existing cap changed)lib/tenancy/userPersonas.ts— wrap both branches ofcreateUserPersonain adb.transaction+ add the initial version insert; add pre-edit-snapshot version capture toupdateUserPersonaBody; addlistPersonaVersions,getPersonaVersion,rollbackPersona; updatecreateUserPersonasfactory — review finding 2.2 Install and register GitHub Actions self-hosted runner #2app/api/settings/personas/[id]/versions/route.ts+ a personaswire.tsREST gate (requireUserId) — list endpoint — review finding 1.1 Create GitHub repo invincible #3app/api/settings/personas/[id]/versions/[versionId]/route.ts— get endpointapp/api/settings/personas/[id]/rollback/route.ts— rollback endpointapp/settings/personas/PersonaForm.tsx— add version history panelTesting
createUserPersonainserts an initial version row atomically (body matches; normal + isDefault branches)lib/tenancy/userPersonas.test.tsupdateUserPersonaBodyafter a normal create captures ONLY the new body (1 row — pre-edit body already stored from create)lib/tenancy/userPersonas.test.tsupdateUserPersonaBodyon 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)lib/tenancy/userPersonas.test.tsupdateUserPersonaBodyat version cap → rejected, body unchangedlib/tenancy/userPersonas.test.tslistPersonaVersionsreturns newest-first summaries, no body, ownership-gated (non-owner → [])lib/tenancy/userPersonas.test.tsgetPersonaVersionreturns body by version id, ownership-gated, no-existence-leak (null)lib/tenancy/userPersonas.test.tsrollbackPersonacopies version body → live row + inserts new version row (atomic)lib/tenancy/userPersonas.test.tsrollbackPersonaat version cap → rejected, body unchangedlib/tenancy/userPersonas.test.tsGET /api/settings/personas/[id]/versions→ version summaries (401 unauth / happy)app/api/settings/personas/route.test.tsGET .../versions/[versionId]→ body (raw text); non-owner/missing → 404POST .../rollback→ success + version row; cap → 400npm run typecheck,vitest run(full, di-gate vianpm test),npm run buildMinimum locked: #1–#11 (store + route tests) + typecheck + full vitest + build (agent workspace/CI). No Zig/native change →
build-harnessdoes not apply; #735 taught that the journal-registered migration + a realdb-migraterun (dry_run then confirm=migrate) plus the Settings smoke is the authoritative DB gate.Definition of done
user_persona_versionstable (schema + migration) exists and is registered at idx 15 indb/migrations/meta/_journal.json(review finding 2.1 Provision DigitalOcean droplet for builds #1)createUserPersona(both branches) +updateUserPersonaBodycapture version rows atomically (review finding 2.2 Install and register GitHub Actions self-hosted runner #2)listPersonaVersions,getPersonaVersion,rollbackPersonain store + DI factory, returningUserPersonasResult<...>versions,versions/[versionId],rollback) present under/api/settings/personas, gated by the RESTrequireUserId(wire.ts) — review finding 1.1 Create GitHub repo invincible #3PersonaForm.tsxshows version history panel with RestorePERSONA_VERSION_MAX = 100inlib/sessionCloudCaps.ts+ Caps table (NEW cap; no existing cap raised/lowered)npm run typecheck,vitest run(full),npm run build(agent workspace/CI)db-migrateprimary path used;_journal.jsonregistered so the job actually applies 0015 (smoke: Settings → Personas → edit body → version row appears)docs/personas.md(new §Version history & rollback) +docs/harness-limits.md+AGENTS.mdupdatedRisks & mitigations
db-migrateruns green yet the table is never created, surfacing "unavailable" (the exact #735 regression)0015_user_persona_versionsat journal idx 15 in the same PR (Implementation order step 2 + DoD + Cloud ops smoke); smoke verifies the panel actually works after migratecreateUserPersona's non-default path is not currently transactional → version insert could be non-atomic with the persona insertdb.transaction(mirrorcreateUserSkill), review finding #2update_bodydiverges from skill patternWHERE body = prevBodycheck + stamped −1 ms pair, inside one transactionPERSONA_VERSION_MAX= 100 is generous; no risk of storage blowout; no cap changedPersonaForm.tsxgrows large with version panelSkillForm.tsx— same scale; extract to a shared component if neededrequireUserId/requireSessionUsergate, same response shape) — NOT the server-actionrequireSettingsSessiongatedb-migrate(existing workflow) — never laptop npmOpen questions
None (in-scope engineering choices are locked above). Restoring deleted personas is explicitly out of scope (non-goal).
References
lib/tenancy/userSkills.ts(lines 690–913)app/settings/skills/SkillForm.tsx(lines 636–770)app/api/settings/skills/[id]/versions/,...[versionId]/,.../rollback/(+wire.tsrequireUserId)lib/sessionCloudCaps.ts(SKILL_VERSION_MAX= 100)lib/tenancy/userPersonas.ts(createUserPersona,updateUserPersonaBody, DI factory,PERSONA_BODY_MAX_BYTES= 16 KiB)db/migrations/0012_user_skill_versions.sql,db/migrations/meta/_journal.json(the fix(db): register 0012–0014 in drizzle journal so db-migrate applies them #735 registration regression).github/workflows/db-migrate.yml(confirm=migrate, dry_run, ubuntu-latest)