feat(ledger): code_shapes — the shape ledger schema (#2787, milestone 294 step 1)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 13s
CI & Build / integration (push) Successful in 25s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 45s

The accounting half of the pattern system (governing note 2786): the snippet
library records canon (small), this table accounts for EVERY extracted shape
(total). Identity is (project, repo_key, path, symbol, kind) — kind included
because one file can define '.foo' (css) and 'foo' (sym) as distinct shapes.
Status vocabulary: canonical / instance / variant / exempt / unclassified,
with unclassified as the default and THE todo state; classifications carry
who judged (agent|audit|hook|mechanical|import), when, and the why for
variants/exemptions. first/last-seen commits + vanished_at keep history
instead of deleting it; a rename reads as vanish+new (accepted for v1).

snippet_id is SET NULL on snippet deletion so accounting rows outlive their
target and rejoin the todo via the step-2 sync, never dangle silently.

Backups: v7 carries code_shapes (judgment data, worth moving) — full and
per-user export sections, and a restore that keeps a judgment only when its
snippet survives the id re-mapping, downgrading to unclassified otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:25:25 -04:00
co-authored by Claude Fable 5
parent 1faf8f3ece
commit 19fdc9aa89
6 changed files with 282 additions and 9 deletions
+56 -2
View File
@@ -11,6 +11,7 @@ from scribe.models.note_supersession import NoteSupersession
from scribe.models.note_version import NoteVersion
from scribe.models.design_system import DesignSystem, DesignToken
from scribe.models.note_usage import NoteUsageEvent
from scribe.models.code_shape import CodeShape
from scribe.models.project import Project
from scribe.models.repo_binding import RepoBinding
from scribe.models.rulebook import (
@@ -36,8 +37,11 @@ logger = logging.getLogger(__name__)
# v6 (2026-08) added note_supersessions — and the guard did stop the seventh:
# the table shipped without a backup section and the coverage test failed the
# build, which is the whole reason that list was written.
# v7 (2026-08) added code_shapes — the shape ledger (#2787). Classifications
# are judgment data worth carrying; a restore keeps a judgment only when its
# snippet target survives the id re-mapping, else the row rejoins the todo.
# Bump when the serialized schema changes.
BACKUP_VERSION = 6
BACKUP_VERSION = 7
# 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
@@ -55,6 +59,8 @@ _BACKED_UP = [
# v5 (2026-08): the five-year gap this list was written to stop.
"systems", "record_systems", "design_systems", "design_tokens",
"note_usage_events", "repo_bindings", "note_supersessions",
# v7 (2026-08): the shape ledger (#2787).
"code_shapes",
]
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
@@ -166,6 +172,10 @@ def _usage_event_rows(rows) -> list[dict]:
]
def _code_shape_rows(rows) -> list[dict]:
return [r.to_dict() for r in rows]
def _repo_binding_rows(rows) -> list[dict]:
return [
{"user_id": r.user_id, "project_id": r.project_id, "repo_key": r.repo_key}
@@ -205,6 +215,7 @@ async def export_full_backup() -> dict:
design_tokens = (await session.execute(select(DesignToken))).scalars().all()
usage_events = (await session.execute(select(NoteUsageEvent))).scalars().all()
repo_bindings = (await session.execute(select(RepoBinding))).scalars().all()
code_shapes = (await session.execute(select(CodeShape))).scalars().all()
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).scalars().all()
rules = (await session.execute(select(Rule))).scalars().all()
@@ -379,6 +390,7 @@ async def export_full_backup() -> dict:
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
}
@@ -446,6 +458,11 @@ async def export_user_backup(user_id: int) -> dict:
repo_bindings = (await session.execute(
select(RepoBinding).where(RepoBinding.user_id == user_id)
)).scalars().all()
# The ledger has no user_id of its own — rows belong to the project
# they account for, so a user's export carries their projects' rows.
code_shapes = (await session.execute(
select(CodeShape).where(CodeShape.project_id.in_(project_ids))
)).scalars().all() if project_ids else []
rulebooks = (await session.execute(
select(Rulebook).where(Rulebook.owner_user_id == user_id)
)).scalars().all()
@@ -634,6 +651,7 @@ async def export_user_backup(user_id: int) -> dict:
"note_usage_events": _usage_event_rows(usage_events),
"repo_bindings": _repo_binding_rows(repo_bindings),
"note_supersessions": _note_supersession_rows(supersessions),
"code_shapes": _code_shape_rows(code_shapes),
}
@@ -737,7 +755,7 @@ async def _restore_v2(data: dict) -> dict:
"topic_suppressions": 0,
"systems": 0, "record_systems": 0, "design_systems": 0,
"design_tokens": 0, "note_usage_events": 0, "repo_bindings": 0,
"note_supersessions": 0,
"note_supersessions": 0, "code_shapes": 0,
}
async with async_session() as session:
@@ -1114,6 +1132,42 @@ async def _restore_v2(data: dict) -> dict:
))
stats["repo_bindings"] += 1
# 21. Code shapes (v7, #2787) — the ledger's classifications are
# judgments worth carrying. A judgment whose snippet target didn't
# survive the re-mapping (canonical/instance/variant with a gone
# snippet) is downgraded to unclassified so it rejoins the todo
# honestly instead of dangling; exempt needs no target and keeps.
for cs_data in data.get("code_shapes", []):
mapped_pid = project_id_map.get(cs_data.get("project_id", 0))
if mapped_pid is None:
continue
status = cs_data.get("status", "unclassified")
mapped_sid = note_id_map.get(cs_data.get("snippet_id") or 0)
classified_by = cs_data.get("classified_by")
classified_at = cs_data.get("classified_at")
if status in ("canonical", "instance", "variant") and mapped_sid is None:
status = "unclassified"
classified_by = None
classified_at = None
session.add(CodeShape(
project_id=mapped_pid,
repo_key=cs_data.get("repo_key", ""),
path=cs_data.get("path", ""),
symbol=cs_data.get("symbol", ""),
kind=cs_data.get("kind", "sym"),
status=status,
snippet_id=mapped_sid,
reason=cs_data.get("reason"),
classified_by=classified_by,
classified_at=_dt(classified_at) if classified_at else None,
first_seen_commit=cs_data.get("first_seen_commit", ""),
last_seen_commit=cs_data.get("last_seen_commit", ""),
vanished_at=_dt(cs_data["vanished_at"]) if cs_data.get("vanished_at") else None,
created_at=_dt(cs_data.get("created_at")),
updated_at=_dt(cs_data.get("updated_at")),
))
stats["code_shapes"] += 1
await session.commit()
logger.info("Restored v2/v3 backup: %s", stats)