From 38d02d9c6fab27bbda20b509047e8ea86dde974a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 15 Oct 2026 19:55:52 -0400 Subject: [PATCH] fix(523): the backup carries user_profiles - the Profile page travels with its user (#5879) Settings -> Profile (name, role, expertise, response style, tone, interests, working hours) is typed by its user and neither derived nor a credential, yet _NOT_INCLUDED named it, so a restore dropped it without a word. BACKUP_VERSION 26. Both exports carry it; the restore remaps only the user, and a blank field stays blank rather than restoring as the page's default. The column and import guards cover it, and the tar round-trip restores one. Co-Authored-By: Claude Opus 5.5 --- src/scribe/services/backup.py | 71 ++++++++++++++++++- tests/test_integration_backup_file_restore.py | 15 ++++ tests/test_services_backup.py | 9 ++- 3 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py index 9adda6a5..2d8e042a 100644 --- a/src/scribe/services/backup.py +++ b/src/scribe/services/backup.py @@ -38,6 +38,7 @@ from scribe.models.setting import Setting from scribe.models.system import RecordSystem, System from scribe.models.task_log import TaskLog from scribe.models.user import User +from scribe.models.user_profile import UserProfile logger = logging.getLogger(__name__) @@ -122,8 +123,12 @@ logger = logging.getLogger(__name__) # every project's recorded answer to a shared idea, and the log is the # precedent each later answer follows. Lose either and every assessment is # owed again, made fresh, with nothing to keep it consistent with the last. +# v26 (2026-10) added user_profiles (#5879): the Profile page — name, role, +# expertise, response style, tone, interests, working hours. Every field is +# typed by its user and none is derived or a credential, so leaving it out made +# a restore drop it without a word. # Bump when the serialized schema changes. -BACKUP_VERSION = 25 +BACKUP_VERSION = 26 # Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED # below, these two lists must together account for the entire schema — which is @@ -180,6 +185,8 @@ _BACKED_UP = [ # memberships. "platforms", "project_platforms", "family_ideas", "family_idea_platforms", "family_idea_references", "family_adoptions", "family_decisions", + # v26 (2026-10): what each user wrote on their Profile page (#5879). + "user_profiles", ] # Tables intentionally NOT in the backup, surfaced in the payload so the gap is @@ -198,7 +205,7 @@ _NOT_INCLUDED = [ "api_keys", "note_embeddings", "rule_embeddings", "milestone_embeddings", "system_embeddings", "app_logs", "notifications", - "invitation_tokens", "password_reset_tokens", "user_profiles", + "invitation_tokens", "password_reset_tokens", "retrieval_logs", # Verdicts on retrieval_logs rows (#4772), so they go where their subject # goes: a restore that carried a judgment of a call it did not carry would @@ -292,6 +299,8 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = { "retrieval_tuning_events": {"id"}, # Every other column is the correction itself, or who made it and why. "moment_mappings": {"id"}, + # One row per user, keyed by the user; every other column is what they typed. + "user_profiles": {"id"}, "design_systems": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"}, "design_tokens": {"deleted_at", "deleted_batch_id", "created_at", "updated_at"}, "repo_bindings": {"id", "created_at", "updated_at"}, @@ -394,6 +403,7 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = { "system_usage_events": {"id"}, "retrieval_tuning_events": {"id"}, "moment_mappings": {"id"}, + "user_profiles": {"id"}, "design_systems": { "id", "deleted_at", "deleted_batch_id", "created_at", "updated_at", }, @@ -570,6 +580,24 @@ def _moment_mapping_rows(rows) -> list[dict]: ] +def _user_profile_rows(rows) -> list[dict]: + """The Profile page (#5879). Not `to_dict()`: that one fills blanks with + the page's defaults and omits `user_id`, and a restore needs the stored + value and the owner.""" + return [ + { + "user_id": r.user_id, "display_name": r.display_name, + "job_title": r.job_title, "industry": r.industry, + "expertise_level": r.expertise_level, + "response_style": r.response_style, "tone": r.tone, + "interests": r.interests, "work_schedule": r.work_schedule, + "created_at": r.created_at.isoformat() if r.created_at else None, + "updated_at": r.updated_at.isoformat() if r.updated_at else None, + } + for r in rows + ] + + def _retrieval_tuning_event_rows(rows) -> list[dict]: """The record of why a retrieval dial is where it is (#4102). @@ -1068,6 +1096,9 @@ async def export_full_backup() -> dict: moment_mappings = (await session.execute( select(MomentMapping).order_by(MomentMapping.id) )).scalars().all() + user_profiles = (await session.execute( + select(UserProfile).order_by(UserProfile.id) + )).scalars().all() repo_bindings = (await session.execute(select(RepoBinding).order_by(RepoBinding.id))).scalars().all() code_shapes = (await session.execute(select(CodeShape).order_by(CodeShape.id))).scalars().all() code_shape_events = (await session.execute( @@ -1142,6 +1173,7 @@ async def export_full_backup() -> dict: retrieval_tuning_events ), "moment_mappings": _moment_mapping_rows(moment_mappings), + "user_profiles": _user_profile_rows(user_profiles), "repo_bindings": _repo_binding_rows(repo_bindings), "note_supersessions": _note_supersession_rows(supersessions), "code_shapes": _code_shape_rows(code_shapes), @@ -1327,6 +1359,9 @@ async def export_user_backup(user_id: int) -> dict: .where(MomentMapping.user_id == user_id) .order_by(MomentMapping.id) )).scalars().all() + user_profiles = (await session.execute( + select(UserProfile).where(UserProfile.user_id == user_id) + )).scalars().all() rule_relations = (await session.execute( select(RuleRelation).where( RuleRelation.from_rule_id.in_(_rule_ids), @@ -1429,6 +1464,7 @@ async def export_user_backup(user_id: int) -> dict: retrieval_tuning_events ), "moment_mappings": _moment_mapping_rows(moment_mappings), + "user_profiles": _user_profile_rows(user_profiles), "repo_bindings": _repo_binding_rows(repo_bindings), "note_supersessions": _note_supersession_rows(supersessions), "code_shapes": _code_shape_rows(code_shapes), @@ -1661,6 +1697,27 @@ def _build_moment_mapping(row: dict, maps: _Maps) -> MomentMapping | None: ) +def _build_user_profile(row: dict, maps: _Maps) -> UserProfile | None: + """Only the user remaps. A blank field stays blank, never the page's + default: `to_dict()` fills those in on read, from the same None.""" + uid = maps.users.get(row.get("user_id") or 0) + if uid is None: + return None + return UserProfile( + user_id=uid, + display_name=row.get("display_name"), + job_title=row.get("job_title"), + industry=row.get("industry"), + expertise_level=row.get("expertise_level"), + response_style=row.get("response_style"), + tone=row.get("tone"), + interests=row.get("interests"), + work_schedule=row.get("work_schedule"), + created_at=_dt(row.get("created_at")), + updated_at=_dt(row.get("updated_at")), + ) + + def _build_retrieval_tuning_event(row: dict, maps: _Maps) -> RetrievalTuningEvent | None: """No id remapping beyond the user: `surface` is a registry NAME, not a foreign key, which is what lets this history survive a restore into an @@ -2377,7 +2434,7 @@ async def _restore_v2(data: dict) -> dict: "rule_systems": 0, "rule_moments": 0, "rule_moment_judgments": 0, "rule_relations": 0, "rule_versions": 0, "retrieval_tuning_events": 0, "lesson_rule_links": 0, - "lesson_no_rule": 0, "moment_mappings": 0, + "lesson_no_rule": 0, "moment_mappings": 0, "user_profiles": 0, "platforms": 0, "project_platforms": 0, "family_ideas": 0, "family_idea_platforms": 0, "family_idea_references": 0, "family_adoptions": 0, "family_decisions": 0, @@ -2499,6 +2556,14 @@ async def _restore_v2(data: dict) -> dict: session.add(mapping) stats["moment_mappings"] += 1 + # 8d. User profiles (v26) — one per user, so only the user remaps. + for up_data in data.get("user_profiles", []): + profile = _build_user_profile(up_data, maps) + if profile is None: + continue + session.add(profile) + stats["user_profiles"] += 1 + # 9. Rulebooks (v3) for rb_data in data.get("rulebooks", []): rb = _build_rulebook(rb_data, maps) diff --git a/tests/test_integration_backup_file_restore.py b/tests/test_integration_backup_file_restore.py index 913ba7ac..13ce1414 100644 --- a/tests/test_integration_backup_file_restore.py +++ b/tests/test_integration_backup_file_restore.py @@ -16,6 +16,7 @@ from scribe.models.milestone import Milestone from scribe.models.note import Note from scribe.models.project import Project from scribe.models.user import User +from scribe.models.user_profile import UserProfile from scribe.restore import load_backup, run from scribe.services import backup from scribe.services.backup_file import write_archive @@ -64,6 +65,10 @@ async def written(tmp_path): title="Prove the restore", body="From the tar, not the JSON.", status="todo", note_type="note", task_kind="work") s.add(note) + profile = UserProfile(user_id=user.id, display_name="Drill Owner", + tone="technical", interests=["backups"], + work_schedule={"start": "09:00"}) + s.add(profile) await s.commit() payload = { "version": backup.BACKUP_VERSION, @@ -72,6 +77,7 @@ async def written(tmp_path): "projects": backup._project_rows([project]), "milestones": backup._milestone_rows([milestone]), "notes": backup._note_rows([note]), + "user_profiles": backup._user_profile_rows([profile]), } payload["users"][0]["username"] = RESTORED from datetime import datetime, timezone @@ -88,10 +94,19 @@ async def test_the_tar_restores_with_every_id_remapped(written): project = (await s.execute(select(Project).where(Project.user_id == user.id))).scalars().one() milestone = (await s.execute(select(Milestone).where(Milestone.user_id == user.id))).scalars().one() note = (await s.execute(select(Note).where(Note.user_id == user.id))).scalars().one() + profile = (await s.execute( + select(UserProfile).where(UserProfile.user_id == user.id) + )).scalars().one() assert project.title == "Restore drill" assert (note.project_id, note.milestone_id) == (project.id, milestone.id) assert note.body == "From the tar, not the JSON." assert note.status == "todo" + # The Profile page travels with its user (#5879); a field left blank stays + # blank rather than restoring as the page's default. + assert (profile.display_name, profile.tone) == ("Drill Owner", "technical") + assert profile.interests == ["backups"] + assert profile.work_schedule == {"start": "09:00"} + assert profile.job_title is None async def test_the_command_refuses_a_database_with_accounts(written): diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py index eeefdfed..cc280d40 100644 --- a/tests/test_services_backup.py +++ b/tests/test_services_backup.py @@ -27,7 +27,7 @@ def test_backup_version_is_current(): (Named for the number it asserted until v10, which is exactly the drift a name-carrying-a-value invites; it now says what it checks.)""" - assert backup.BACKUP_VERSION == 25 + assert backup.BACKUP_VERSION == 26 def _exportable_note(**over): @@ -166,6 +166,7 @@ def _column_guard_targets(): from scribe.models.system import RecordSystem, System from scribe.models.task_log import TaskLog from scribe.models.user import User + from scribe.models.user_profile import UserProfile return { "users": (User, backup._user_rows), @@ -213,6 +214,7 @@ def _column_guard_targets(): "family_idea_references": (FamilyIdeaReference, backup._family_idea_reference_rows), "family_adoptions": (FamilyAdoption, backup._family_adoption_rows), "family_decisions": (FamilyDecision, backup._family_decision_rows), + "user_profiles": (UserProfile, backup._user_profile_rows), } @@ -326,6 +328,7 @@ def _import_guard_targets(): "system_usage_events": backup._build_system_usage_event, "retrieval_tuning_events": backup._build_retrieval_tuning_event, "moment_mappings": backup._build_moment_mapping, + "user_profiles": backup._build_user_profile, "design_systems": backup._build_design_system, "design_tokens": backup._build_design_token, "repo_bindings": backup._build_repo_binding, @@ -599,7 +602,9 @@ async def test_export_full_backup_contains_every_declared_section(): # v25: family canon (milestone 463). "platforms", "project_platforms", "family_ideas", "family_idea_platforms", "family_idea_references", - "family_adoptions", "family_decisions"): + "family_adoptions", "family_decisions", + # v26: the Profile page (#5879). + "user_profiles"): assert key in out, f"missing export section: {key}" assert out[key] == [] -- 2.54.0