feat(6845): every instance has an id lineage, and every full backup carries it
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 1m37s
CI & Build / integration (push) Successful in 2m7s
CI & Build / Build & push image (push) Successful in 22s

instance_identity is one row made by migration 0127 with a random id, so an
instance has it before its first account. A full backup carries it in its
header as instance_id (the tar manifest header and the JSON top level);
BACKUP_VERSION is 28. The table rides in _NOT_INCLUDED: a restore decides from
the header value rather than copying a row.

Two stores with the same id number their rows the same way. That is what lets
a later step roll a live instance back by original id, refuse a backup whose
ids would land on unrelated rows, and have a fresh-instance restore adopt the
backup's id (adopt_instance_id).

Milestone 582, step 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 19:53:45 -05:00
co-authored by Claude Opus 5.5
parent 3c01e17559
commit 6d8db0bb65
7 changed files with 171 additions and 4 deletions
+10 -1
View File
@@ -111,7 +111,9 @@ logger = logging.getLogger(__name__)
# v26 user_profiles: what each user wrote on their Profile page.
# v27 drops user_profiles with the Profile page. Older archives carrying the
# section still restore — the key is simply not read.
BACKUP_VERSION = 27
# v28 instance_id in the header: the id lineage of the store the backup came
# from. A live instance rolls back only to a backup with its own id.
BACKUP_VERSION = 28
# 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
@@ -195,6 +197,9 @@ _NOT_INCLUDED = [
# rebuilt from the repo archive by every coverage sync, and carries no
# judgment — the first refresh after a restore recreates it exactly.
"code_shape_consumers",
# Not a section: the full backup carries the one value in its header as
# `instance_id`, and a restore decides from it rather than copying a row.
"instance_identity",
]
@@ -979,7 +984,10 @@ async def export_full_backup() -> dict:
runs, which only works when unchanged rows serialise to the same bytes in
the same place.
"""
from grimoire.services.instance import get_instance_id
async with async_session() as session:
instance_id = await get_instance_id(session)
users =(await session.execute(select(User).order_by(User.id))).scalars().all()
projects = (await session.execute(select(Project).order_by(Project.id))).scalars().all()
milestones = (await session.execute(select(Milestone).order_by(Milestone.id))).scalars().all()
@@ -1077,6 +1085,7 @@ async def export_full_backup() -> dict:
return {
"version": BACKUP_VERSION,
"scope": "full",
"instance_id": instance_id,
"exported_at": datetime.now(timezone.utc).isoformat(),
"_security_notice": (
"This backup contains hashed passwords. "