Merge dev: family canon, milestone 463 steps 1-6 #206
@@ -0,0 +1,288 @@
|
|||||||
|
"""family canon — platforms, family ideas, the adoption ledger and its decision
|
||||||
|
log (milestone 463 step 1)
|
||||||
|
|
||||||
|
Revision ID: 0120
|
||||||
|
Revises: 0119
|
||||||
|
Create Date: 2026-10-06
|
||||||
|
|
||||||
|
When one project solves something every project on the same platform will
|
||||||
|
meet, that solution becomes FAMILY CANON, and every other project on the
|
||||||
|
platform answers it: adopted, a variant (with a reason), exempt (with a
|
||||||
|
reason), or owed. These tables hold that.
|
||||||
|
|
||||||
|
- `platforms` — a GLOBAL catalog, the canonical_systems shape: no owner, slug
|
||||||
|
is the match key. Seeded with generic technology names only. Nothing here
|
||||||
|
names an app, a repo or a house convention: the catalog ships to every
|
||||||
|
install (rule 115).
|
||||||
|
- `project_platforms` — which platforms a project is (declared / detected /
|
||||||
|
rejected). Membership is what makes "is this in family?" a lookup.
|
||||||
|
- `family_ideas` — a NOTE's family state. No new record type: any notes row
|
||||||
|
(a note, a snippet, a lesson) becomes an idea by gaining one of these.
|
||||||
|
- `family_idea_platforms` — the platforms an idea is for; the only scope
|
||||||
|
source. A linked rule topic takes its scope from the idea.
|
||||||
|
- `family_idea_references` — reference implementations (snippets).
|
||||||
|
- `family_adoptions` — one row per (project, idea): the project's answer.
|
||||||
|
- `family_decisions` — append-only log of every promotion and ledger change.
|
||||||
|
|
||||||
|
No backfill. No project has declared a platform and no note is an idea yet;
|
||||||
|
inventing either would assert a judgment nobody made. The seed rows are
|
||||||
|
written here verbatim rather than imported, so the migration keeps running
|
||||||
|
unchanged after any service-side list moves on.
|
||||||
|
"""
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0120"
|
||||||
|
down_revision = "0119"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# One place per whitelist, so each CHECK and the model's tuple cannot drift
|
||||||
|
# (rule 36: a new value later means DROP + ADD CONSTRAINT in one migration).
|
||||||
|
_MEMBERSHIP = ("declared", "detected", "rejected")
|
||||||
|
_IDEA_STATUSES = ("candidate", "canon", "retired")
|
||||||
|
_ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
|
||||||
|
_REASONED = ("variant", "exempt")
|
||||||
|
_ACTIONS = ("propose", "promote", "revise", "retire", "assess", "undo")
|
||||||
|
_DECIDERS = ("agent", "operator", "system")
|
||||||
|
|
||||||
|
|
||||||
|
def _in(column: str, values: tuple[str, ...]) -> str:
|
||||||
|
return f"{column} IN (" + ", ".join(f"'{v}'" for v in values) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
# (name, slug, description, markers). A marker with no slash matches a file's
|
||||||
|
# basename anywhere in a repo; one with a slash matches the repo-relative
|
||||||
|
# path. Empty markers = declare-only, for a platform that leaves no reliable
|
||||||
|
# file behind. Generic on purpose — an install adds its own.
|
||||||
|
_SEED = (
|
||||||
|
("Android app", "android-app",
|
||||||
|
"A native Android client: an APK or app bundle installed on phones and tablets.",
|
||||||
|
["AndroidManifest.xml"]),
|
||||||
|
("iOS app", "ios-app",
|
||||||
|
"A native iOS or iPadOS client built with Xcode.",
|
||||||
|
["project.pbxproj"]),
|
||||||
|
("Web frontend", "web-frontend",
|
||||||
|
"A browser client built with a frontend toolchain: single-page apps and server-rendered frontends.",
|
||||||
|
["vite.config.*", "svelte.config.*", "vue.config.*", "next.config.*",
|
||||||
|
"nuxt.config.*", "angular.json"]),
|
||||||
|
("Browser extension", "browser-extension",
|
||||||
|
"An extension installed into a web browser and distributed through its add-on store or by file.",
|
||||||
|
[]),
|
||||||
|
("Desktop app", "desktop-app",
|
||||||
|
"A packaged desktop application for Windows, macOS or Linux.",
|
||||||
|
["tauri.conf.json", "electron-builder.*"]),
|
||||||
|
("Container image", "container-image",
|
||||||
|
"Ships as an OCI/Docker image that a host pulls and runs.",
|
||||||
|
["Dockerfile", "Containerfile", "*.Dockerfile"]),
|
||||||
|
("Go", "go",
|
||||||
|
"Written in Go.",
|
||||||
|
["go.mod"]),
|
||||||
|
("Python", "python",
|
||||||
|
"Written in Python.",
|
||||||
|
["pyproject.toml", "setup.py", "requirements.txt"]),
|
||||||
|
("Rust", "rust",
|
||||||
|
"Written in Rust.",
|
||||||
|
["Cargo.toml"]),
|
||||||
|
("PostgreSQL", "postgresql",
|
||||||
|
"Keeps its data in PostgreSQL.",
|
||||||
|
[]),
|
||||||
|
("GitHub Actions", "github-actions",
|
||||||
|
"Verified and released by GitHub Actions workflows.",
|
||||||
|
[".github/workflows/*"]),
|
||||||
|
("Gitea / Forgejo Actions", "gitea-actions",
|
||||||
|
"Verified and released by Gitea or Forgejo Actions workflows.",
|
||||||
|
[".gitea/workflows/*", ".forgejo/workflows/*"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
platforms = op.create_table(
|
||||||
|
"platforms",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("slug", sa.Text(), nullable=False),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("markers", postgresql.JSONB(), nullable=False,
|
||||||
|
server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
# Unique among LIVE rows only (the canonical_systems convention).
|
||||||
|
op.create_index(
|
||||||
|
"uq_platforms_slug", "platforms", ["slug"],
|
||||||
|
unique=True, postgresql_where=sa.text("deleted_at IS NULL"),
|
||||||
|
)
|
||||||
|
op.bulk_insert(
|
||||||
|
platforms,
|
||||||
|
[
|
||||||
|
{"name": name, "slug": slug, "description": description,
|
||||||
|
"markers": markers, "order_index": index}
|
||||||
|
for index, (name, slug, description, markers) in enumerate(_SEED)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"project_platforms",
|
||||||
|
sa.Column("project_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("platform_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("state", sa.Text(), nullable=False, server_default="declared"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_project_platforms_state", "project_platforms", _in("state", _MEMBERSHIP),
|
||||||
|
)
|
||||||
|
op.create_index("ix_project_platforms_platform_id", "project_platforms", ["platform_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"family_ideas",
|
||||||
|
sa.Column("note_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="candidate"),
|
||||||
|
sa.Column("applies_when", sa.Text(), nullable=True),
|
||||||
|
sa.Column("canon_version", sa.Integer(), nullable=False, server_default="1"),
|
||||||
|
# SET NULL: deleting a topic must not delete the standard it served.
|
||||||
|
sa.Column("topic_id", sa.BigInteger(),
|
||||||
|
sa.ForeignKey("rulebook_topics.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_ideas_status", "family_ideas", _in("status", _IDEA_STATUSES),
|
||||||
|
)
|
||||||
|
# The first promotion criterion, held by the schema: an idea is not canon
|
||||||
|
# until it says, in platform terms, when it applies.
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_ideas_canon_applies", "family_ideas",
|
||||||
|
"status <> 'canon' OR length(btrim(coalesce(applies_when, ''))) > 0",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_ideas_canon_version", "family_ideas", "canon_version >= 1",
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_family_ideas_topic", "family_ideas", ["topic_id"],
|
||||||
|
unique=True, postgresql_where=sa.text("topic_id IS NOT NULL"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_family_ideas_status", "family_ideas", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"family_idea_platforms",
|
||||||
|
sa.Column("note_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("platform_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_family_idea_platforms_platform_id", "family_idea_platforms", ["platform_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"family_idea_references",
|
||||||
|
sa.Column("idea_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("snippet_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
op.create_index("ix_family_idea_references_snippet_id", "family_idea_references", ["snippet_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"family_adoptions",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||||
|
sa.Column("project_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("idea_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("status", sa.Text(), nullable=False, server_default="unassessed"),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=True),
|
||||||
|
sa.Column("canon_version", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("assessed_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("decided_via", sa.Text(), nullable=True),
|
||||||
|
sa.Column("owed_task_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_adoptions_status", "family_adoptions", _in("status", _ADOPTION_STATUSES),
|
||||||
|
)
|
||||||
|
# A departure is only a record if it says why.
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_adoptions_reason", "family_adoptions",
|
||||||
|
"NOT (" + _in("status", _REASONED) + ") "
|
||||||
|
"OR length(btrim(coalesce(reason, ''))) > 0",
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_adoptions_decided_via", "family_adoptions",
|
||||||
|
"decided_via IS NULL OR " + _in("decided_via", _DECIDERS),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"uq_family_adoptions_pair", "family_adoptions", ["project_id", "idea_id"], unique=True,
|
||||||
|
)
|
||||||
|
op.create_index("ix_family_adoptions_project_id", "family_adoptions", ["project_id"])
|
||||||
|
op.create_index("ix_family_adoptions_idea_id", "family_adoptions", ["idea_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"family_decisions",
|
||||||
|
sa.Column("id", sa.BigInteger(), primary_key=True),
|
||||||
|
sa.Column("idea_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("family_ideas.note_id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("project_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("action", sa.Text(), nullable=False),
|
||||||
|
sa.Column("reason", sa.Text(), nullable=False),
|
||||||
|
sa.Column("before", postgresql.JSONB(), nullable=True),
|
||||||
|
sa.Column("after", postgresql.JSONB(), nullable=True),
|
||||||
|
sa.Column("evidence", postgresql.JSONB(), nullable=True),
|
||||||
|
sa.Column("precedent_ids", postgresql.JSONB(), nullable=False,
|
||||||
|
server_default=sa.text("'[]'::jsonb")),
|
||||||
|
sa.Column("decided_via", sa.Text(), nullable=False, server_default="agent"),
|
||||||
|
sa.Column("user_id", sa.Integer(),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_decisions_action", "family_decisions", _in("action", _ACTIONS),
|
||||||
|
)
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_decisions_decided_via", "family_decisions", _in("decided_via", _DECIDERS),
|
||||||
|
)
|
||||||
|
# The reason IS the record — the agent decides with no approval step, so
|
||||||
|
# a decision that cannot say why is not one anybody can review or follow.
|
||||||
|
op.create_check_constraint(
|
||||||
|
"ck_family_decisions_reason", "family_decisions", "length(btrim(reason)) > 0",
|
||||||
|
)
|
||||||
|
op.create_index("ix_family_decisions_idea_id", "family_decisions", ["idea_id"])
|
||||||
|
op.create_index("ix_family_decisions_project_id", "family_decisions", ["project_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_family_decisions_project_id", table_name="family_decisions")
|
||||||
|
op.drop_index("ix_family_decisions_idea_id", table_name="family_decisions")
|
||||||
|
op.drop_table("family_decisions")
|
||||||
|
|
||||||
|
op.drop_index("ix_family_adoptions_idea_id", table_name="family_adoptions")
|
||||||
|
op.drop_index("ix_family_adoptions_project_id", table_name="family_adoptions")
|
||||||
|
op.drop_index("uq_family_adoptions_pair", table_name="family_adoptions")
|
||||||
|
op.drop_table("family_adoptions")
|
||||||
|
|
||||||
|
op.drop_index("ix_family_idea_references_snippet_id", table_name="family_idea_references")
|
||||||
|
op.drop_table("family_idea_references")
|
||||||
|
|
||||||
|
op.drop_index("ix_family_idea_platforms_platform_id", table_name="family_idea_platforms")
|
||||||
|
op.drop_table("family_idea_platforms")
|
||||||
|
|
||||||
|
op.drop_index("ix_family_ideas_status", table_name="family_ideas")
|
||||||
|
op.drop_index("uq_family_ideas_topic", table_name="family_ideas")
|
||||||
|
op.drop_table("family_ideas")
|
||||||
|
|
||||||
|
op.drop_index("ix_project_platforms_platform_id", table_name="project_platforms")
|
||||||
|
op.drop_table("project_platforms")
|
||||||
|
|
||||||
|
op.drop_index("uq_platforms_slug", table_name="platforms")
|
||||||
|
op.drop_table("platforms")
|
||||||
@@ -89,3 +89,9 @@ from scribe.models.forge_connection import ForgeConnection # noqa: E402, F401
|
|||||||
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
|
from scribe.models.code_shape import CodeShape, CodeShapeConsumer, CodeShapeEvent, CodeShapeUse # noqa: E402, F401
|
||||||
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
from scribe.models.system import System, RecordSystem # noqa: E402, F401
|
||||||
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
from scribe.models.design_system import DesignSystem, DesignToken # noqa: E402, F401
|
||||||
|
# After notes, projects and rulebook topics: family canon foreign-keys all
|
||||||
|
# three (milestone 463).
|
||||||
|
from scribe.models.family import ( # noqa: E402, F401
|
||||||
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
|
||||||
|
FamilyIdeaReference, Platform, ProjectPlatform,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""Family canon (milestone 463): ideas that every project on a platform shares,
|
||||||
|
and each project's recorded answer to them.
|
||||||
|
|
||||||
|
A FAMILY IDEA is not a new kind of record. It is an existing `notes` row — a
|
||||||
|
note, a snippet, a lesson — that has been given a platform scope. The note
|
||||||
|
carries the idea (when it applies, the traps, the checklist, the incidents
|
||||||
|
behind them); an optional rulebook topic carries the norms that bind; snippets
|
||||||
|
are the reference implementations, one per language. A new record type would
|
||||||
|
be one more thing reached for interchangeably with rules, processes, snippets
|
||||||
|
and design systems, and nothing here needs one.
|
||||||
|
|
||||||
|
What is shared is the IDEA, not the code. Each project implements it in its own
|
||||||
|
language; identical code is a by-product and never the test.
|
||||||
|
|
||||||
|
The tables, and the one job each does:
|
||||||
|
|
||||||
|
- `platforms` — the global catalog of what a project can be built on or ship
|
||||||
|
as. Same shape and reasoning as `canonical_systems`: no owner, so the same
|
||||||
|
word means the same thing in every project on the install.
|
||||||
|
- `project_platforms` — which platforms a project is. Membership is how
|
||||||
|
"is this in family?" stops being a judgment made per idea and becomes a
|
||||||
|
lookup.
|
||||||
|
- `family_ideas` — a note's family state: candidate, canon or retired, its
|
||||||
|
applicability test, its canon version, its linked rule topic.
|
||||||
|
- `family_idea_platforms` — the platforms an idea is for. The ONLY scope
|
||||||
|
source: a linked topic takes its scope from here rather than carrying its
|
||||||
|
own, so the two can never disagree.
|
||||||
|
- `family_idea_references` — the reference implementations.
|
||||||
|
- `family_adoptions` — one row per (project, idea): the project's answer.
|
||||||
|
- `family_decisions` — the append-only log of every promotion and every
|
||||||
|
ledger change, with its reason and the earlier decisions it followed. The
|
||||||
|
agent makes these calls with no approval step, so the log is how they stay
|
||||||
|
consistent (precedent) and how a person reviews or undoes one.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger, DateTime, ForeignKey, Index, Integer, Text, text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from scribe.models import Base
|
||||||
|
from scribe.models.base import CreatedAtMixin, SoftDeleteMixin, TimestampMixin, iso
|
||||||
|
|
||||||
|
# Each tuple is the whitelist its CHECK constraint enforces (migration 0120).
|
||||||
|
# Rule 36: a new value means DROP + ADD CONSTRAINT in the same migration.
|
||||||
|
MEMBERSHIP_STATES = ("declared", "detected", "rejected")
|
||||||
|
IDEA_STATUSES = ("candidate", "canon", "retired")
|
||||||
|
ADOPTION_STATUSES = ("unassessed", "adopted", "variant", "exempt", "owed")
|
||||||
|
# The two adoption answers that are a departure, and so must say why.
|
||||||
|
REASONED_STATUSES = ("variant", "exempt")
|
||||||
|
DECISION_ACTIONS = ("propose", "promote", "revise", "retire", "assess", "undo")
|
||||||
|
DECIDERS = ("agent", "operator", "system")
|
||||||
|
|
||||||
|
|
||||||
|
class Platform(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""What a project is built on or ships as — a runtime, a delivery channel
|
||||||
|
or a toolchain whose own behaviour causes the problems a family idea
|
||||||
|
answers.
|
||||||
|
|
||||||
|
FLAT, deliberately. A project declares several, and an idea is for
|
||||||
|
several, so "Android app and container image" needs no hierarchy to
|
||||||
|
express. Applicability narrower than a platform (an idea that only matters
|
||||||
|
to a sideloaded APK, not a store-distributed one) belongs in the idea's
|
||||||
|
`applies_when`, where a project it does not fit answers `exempt` with the
|
||||||
|
reason. Splitting platforms to carry that would grow the catalog every
|
||||||
|
time an idea got more specific.
|
||||||
|
|
||||||
|
GLOBAL, like `canonical_systems`: no owner, so a shared project inherits
|
||||||
|
the vocabulary rather than re-earning it. `slug` is the match key and the
|
||||||
|
form a backup carries, since ids are per-install.
|
||||||
|
|
||||||
|
`markers` are glob patterns over a bound repo's paths that suggest a
|
||||||
|
project is this platform (step 2's detection). A pattern without a slash
|
||||||
|
matches a file's basename anywhere in the tree; one with a slash matches
|
||||||
|
the repo-relative path. Empty means declare-only: some platforms leave no
|
||||||
|
reliable file behind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "platforms"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
markers: Mapped[list] = mapped_column(
|
||||||
|
JSONB, default=list, server_default=text("'[]'::jsonb"),
|
||||||
|
)
|
||||||
|
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Unique among LIVE rows, so a retired entry doesn't block recreating
|
||||||
|
# the same platform (the canonical_systems convention).
|
||||||
|
Index(
|
||||||
|
"uq_platforms_slug", "slug",
|
||||||
|
unique=True, postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"slug": self.slug,
|
||||||
|
"description": self.description,
|
||||||
|
"markers": list(self.markers or []),
|
||||||
|
"order_index": self.order_index,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
"updated_at": iso(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectPlatform(Base, CreatedAtMixin):
|
||||||
|
"""A project's answer to "are you this platform?".
|
||||||
|
|
||||||
|
Three states, because detection is automatic and must not be able to
|
||||||
|
override a person:
|
||||||
|
- ``declared`` — someone said so (at inception or in settings).
|
||||||
|
- ``detected`` — a marker in a bound repo said so.
|
||||||
|
- ``rejected`` — someone said NO. Kept as a row rather than deleted, so the
|
||||||
|
next refresh does not detect it straight back. Not membership.
|
||||||
|
|
||||||
|
Only `declared` and `detected` make a project a member of the platform's
|
||||||
|
family.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "project_platforms"
|
||||||
|
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
)
|
||||||
|
platform_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
# CHECK ck_project_platforms_state (migration 0120).
|
||||||
|
state: Mapped[str] = mapped_column(Text, default="declared", server_default="declared")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"platform_id": self.platform_id,
|
||||||
|
"state": self.state,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyIdea(Base, TimestampMixin):
|
||||||
|
"""A note's family state. Its presence is what makes the note a family
|
||||||
|
idea; the note itself is unchanged.
|
||||||
|
|
||||||
|
- ``candidate`` — recorded, not yet proven or not yet stated in platform
|
||||||
|
terms. Reaches nobody's ledger.
|
||||||
|
- ``canon`` — promoted. Every project on its platforms owes it an answer.
|
||||||
|
- ``retired`` — demoted. Kept, with its ledger, so the history reads.
|
||||||
|
|
||||||
|
`applies_when` is the applicability test every assessment starts from —
|
||||||
|
stated in platform terms, which is the first promotion criterion. A CHECK
|
||||||
|
refuses canon without one, so that criterion is the schema's to enforce
|
||||||
|
and not a sentence a session might skip.
|
||||||
|
|
||||||
|
`canon_version` moves whenever the idea's substance changes. An adoption
|
||||||
|
assessed against an older version needs rechecking — which is DERIVED by
|
||||||
|
comparing the two numbers, never stored as a flag that could go stale.
|
||||||
|
|
||||||
|
`topic_id` is the rule topic holding the idea's binding norms, if it has
|
||||||
|
any (the note↔topic link #3236 asked for). The topic takes its scope from
|
||||||
|
this idea; it never carries platforms of its own.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "family_ideas"
|
||||||
|
|
||||||
|
note_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
)
|
||||||
|
# CHECK ck_family_ideas_status (migration 0120).
|
||||||
|
status: Mapped[str] = mapped_column(Text, default="candidate", server_default="candidate")
|
||||||
|
# CHECK ck_family_ideas_canon_applies (migration 0120).
|
||||||
|
applies_when: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
canon_version: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
|
||||||
|
topic_id: Mapped[int | None] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("rulebook_topics.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# One idea per topic: a topic's norms belong to one standard.
|
||||||
|
Index(
|
||||||
|
"uq_family_ideas_topic", "topic_id",
|
||||||
|
unique=True, postgresql_where=text("topic_id IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index("ix_family_ideas_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"note_id": self.note_id,
|
||||||
|
"status": self.status,
|
||||||
|
"applies_when": self.applies_when or "",
|
||||||
|
"canon_version": self.canon_version,
|
||||||
|
"topic_id": self.topic_id,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
"updated_at": iso(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyIdeaPlatform(Base, CreatedAtMixin):
|
||||||
|
"""A platform an idea is for. A join table with a model so the backup's
|
||||||
|
column guard can see it, and so the reverse lookup — every idea for a
|
||||||
|
platform — has an index."""
|
||||||
|
|
||||||
|
__tablename__ = "family_idea_platforms"
|
||||||
|
|
||||||
|
note_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
)
|
||||||
|
platform_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("platforms.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyIdeaReference(Base, CreatedAtMixin):
|
||||||
|
"""A reference implementation of an idea: a snippet a project can start
|
||||||
|
from. Explicit rather than inferred, because an owed task names "the
|
||||||
|
reference for your language", and a guess there sends a project to copy
|
||||||
|
the wrong thing. The snippet's language is read from the snippet."""
|
||||||
|
|
||||||
|
__tablename__ = "family_idea_references"
|
||||||
|
|
||||||
|
idea_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
)
|
||||||
|
snippet_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyAdoption(Base, TimestampMixin):
|
||||||
|
"""One project's answer to one family idea — the adoption ledger.
|
||||||
|
|
||||||
|
- ``unassessed`` — the idea reached the project and nobody has judged it.
|
||||||
|
- ``adopted`` — the project does it.
|
||||||
|
- ``variant`` — the project departs, for a reason that names a fact about
|
||||||
|
itself the canon did not account for. A preference is not a reason.
|
||||||
|
- ``exempt`` — the idea's `applies_when` is false for this project.
|
||||||
|
- ``owed`` — it applies, there is no reason to depart, and it is not done
|
||||||
|
yet. `owed_task_id` is the task filed in THIS project to do it.
|
||||||
|
|
||||||
|
`reason` is required for variant and exempt (CHECK), because those two are
|
||||||
|
the departures and the reason is the whole record. `canon_version` is the
|
||||||
|
idea version this answer was given against; NULL while unassessed.
|
||||||
|
|
||||||
|
The row is the CURRENT answer. How it got there — the reasons at each step
|
||||||
|
and the precedents each followed — is in `family_decisions`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "family_adoptions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("projects.id", ondelete="CASCADE"), index=True,
|
||||||
|
)
|
||||||
|
idea_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), index=True,
|
||||||
|
)
|
||||||
|
# CHECK ck_family_adoptions_status and ck_family_adoptions_reason (0120).
|
||||||
|
status: Mapped[str] = mapped_column(Text, default="unassessed", server_default="unassessed")
|
||||||
|
reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
canon_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
assessed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
# CHECK ck_family_adoptions_decided_via (0120). NULL while unassessed.
|
||||||
|
decided_via: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
owed_task_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("uq_family_adoptions_pair", "project_id", "idea_id", unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"idea_id": self.idea_id,
|
||||||
|
"status": self.status,
|
||||||
|
"reason": self.reason or "",
|
||||||
|
"canon_version": self.canon_version,
|
||||||
|
"assessed_at": iso(self.assessed_at),
|
||||||
|
"decided_via": self.decided_via,
|
||||||
|
"owed_task_id": self.owed_task_id,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
"updated_at": iso(self.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FamilyDecision(Base, CreatedAtMixin):
|
||||||
|
"""One decision about family canon — append-only.
|
||||||
|
|
||||||
|
`project_id` is NULL for a decision about the idea itself (propose,
|
||||||
|
promote, revise, retire) and set for a decision about one project's
|
||||||
|
answer (assess). `undo` reverses an earlier decision and names it in
|
||||||
|
`evidence`.
|
||||||
|
|
||||||
|
`before` and `after` are STATE snapshots — status, version, reason — and
|
||||||
|
deliberately hold no foreign ids: an id inside JSON cannot be remapped by a
|
||||||
|
restore, and would come back pointing at whatever took that number (the
|
||||||
|
#3182 trap). The ids this row needs are columns.
|
||||||
|
|
||||||
|
`precedent_ids` are the earlier decisions this one followed. They are what
|
||||||
|
keeps a call consistent with the last similar one when no person approves
|
||||||
|
either, so they are a list of ids into this same table, remapped at
|
||||||
|
restore through the decision map.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "family_decisions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||||
|
idea_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("family_ideas.note_id", ondelete="CASCADE"), index=True,
|
||||||
|
)
|
||||||
|
project_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True,
|
||||||
|
)
|
||||||
|
# CHECK ck_family_decisions_action (0120).
|
||||||
|
action: Mapped[str] = mapped_column(Text)
|
||||||
|
reason: Mapped[str] = mapped_column(Text)
|
||||||
|
before: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
after: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
evidence: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
|
precedent_ids: Mapped[list] = mapped_column(
|
||||||
|
JSONB, default=list, server_default=text("'[]'::jsonb"),
|
||||||
|
)
|
||||||
|
# CHECK ck_family_decisions_decided_via (0120).
|
||||||
|
decided_via: Mapped[str] = mapped_column(Text, default="agent", server_default="agent")
|
||||||
|
# Who was acting — the session's user. The decider KIND is decided_via.
|
||||||
|
user_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"idea_id": self.idea_id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"action": self.action,
|
||||||
|
"reason": self.reason,
|
||||||
|
"before": self.before,
|
||||||
|
"after": self.after,
|
||||||
|
"evidence": self.evidence or {},
|
||||||
|
"precedent_ids": list(self.precedent_ids or []),
|
||||||
|
"decided_via": self.decided_via,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"created_at": iso(self.created_at),
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ from scribe.models.system_usage import SystemUsageEvent
|
|||||||
from scribe.models.moment_mapping import MomentMapping
|
from scribe.models.moment_mapping import MomentMapping
|
||||||
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
from scribe.models.retrieval_tuning import RetrievalTuningEvent
|
||||||
from scribe.models.canonical_system import CanonicalSystem
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
|
from scribe.models.family import (
|
||||||
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
|
||||||
|
FamilyIdeaReference, Platform, ProjectPlatform,
|
||||||
|
)
|
||||||
from scribe.models.rulebook import (
|
from scribe.models.rulebook import (
|
||||||
RuleRelation, rule_moments as rule_moments_t, rule_systems as rule_systems_t,
|
RuleRelation, rule_moments as rule_moments_t, rule_systems as rule_systems_t,
|
||||||
)
|
)
|
||||||
@@ -112,8 +116,14 @@ logger = logging.getLogger(__name__)
|
|||||||
# v24 (2026-10) added rule_moment_judgments.misfire (milestone 458 step 7b):
|
# v24 (2026-10) added rule_moment_judgments.misfire (milestone 458 step 7b):
|
||||||
# the reports that a mounted rule arrived where it did not apply, and the
|
# the reports that a mounted rule arrived where it did not apply, and the
|
||||||
# operator's "keep it" that stops them being proposed as an unmount again.
|
# operator's "keep it" that stops them being proposed as an unmount again.
|
||||||
|
# v25 (2026-10) added family canon (milestone 463): the platform catalog, each
|
||||||
|
# project's platforms, family ideas with their platforms and reference
|
||||||
|
# implementations, the adoption ledger and the decision log. The ledger is
|
||||||
|
# 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.
|
||||||
# Bump when the serialized schema changes.
|
# Bump when the serialized schema changes.
|
||||||
BACKUP_VERSION = 24
|
BACKUP_VERSION = 25
|
||||||
|
|
||||||
# Every table this backup carries, by its REAL name. Paired with _NOT_INCLUDED
|
# 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
|
# below, these two lists must together account for the entire schema — which is
|
||||||
@@ -164,6 +174,12 @@ _BACKED_UP = [
|
|||||||
"rule_moments",
|
"rule_moments",
|
||||||
# v23 (2026-10): proposals and judgments about those mounts (step 7).
|
# v23 (2026-10): proposals and judgments about those mounts (step 7).
|
||||||
"rule_moment_judgments",
|
"rule_moment_judgments",
|
||||||
|
# v25 (2026-10): family canon (milestone 463). `platforms` is global like
|
||||||
|
# canonical_systems and rides every export for the same reason: the rows
|
||||||
|
# below name platforms by slug, and a partial catalog restores partial
|
||||||
|
# memberships.
|
||||||
|
"platforms", "project_platforms", "family_ideas", "family_idea_platforms",
|
||||||
|
"family_idea_references", "family_adoptions", "family_decisions",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
# Tables intentionally NOT in the backup, surfaced in the payload so the gap is
|
||||||
@@ -298,6 +314,19 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
|||||||
},
|
},
|
||||||
"code_shape_events": set(),
|
"code_shape_events": set(),
|
||||||
"code_shape_uses": set(),
|
"code_shape_uses": set(),
|
||||||
|
# Matched on SLUG at restore, like canonical_systems: a target install
|
||||||
|
# already seeded the standard platforms from its migrations.
|
||||||
|
"platforms": {"deleted_at", "deleted_batch_id", "id", "created_at", "updated_at"},
|
||||||
|
# The platform travels as `platform_slug` — ids are per-install.
|
||||||
|
"project_platforms": {"platform_id"},
|
||||||
|
"family_idea_platforms": {"platform_id"},
|
||||||
|
# Every column travels: the note and topic ids are SOURCE ids, remapped.
|
||||||
|
"family_ideas": set(),
|
||||||
|
"family_idea_references": set(),
|
||||||
|
"family_adoptions": {"id"},
|
||||||
|
# The id travels, unlike the other surrogate keys: precedent_ids point at
|
||||||
|
# it, so the restore needs the source id to build the decision map.
|
||||||
|
"family_decisions": set(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -381,6 +410,15 @@ _IMPORT_COLUMN_EXCLUSIONS: dict[str, set[str]] = {
|
|||||||
},
|
},
|
||||||
"code_shape_events": {"id"},
|
"code_shape_events": {"id"},
|
||||||
"code_shape_uses": {"id"},
|
"code_shape_uses": {"id"},
|
||||||
|
"platforms": {"id", "deleted_at", "deleted_batch_id", "created_at", "updated_at"},
|
||||||
|
# `platform_id` IS set, from the exported slug.
|
||||||
|
"project_platforms": set(),
|
||||||
|
"family_idea_platforms": set(),
|
||||||
|
"family_ideas": set(),
|
||||||
|
"family_idea_references": set(),
|
||||||
|
"family_adoptions": {"id"},
|
||||||
|
# Reason 2: re-issued. The source id only builds the precedent map.
|
||||||
|
"family_decisions": {"id"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -829,6 +867,99 @@ def _lesson_no_rule_rows(rows) -> list[dict]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_rows(rows) -> list[dict]:
|
||||||
|
"""The global platform catalog (v25). Carried WITHOUT ids, matched on slug
|
||||||
|
at restore — the canonical_systems reasoning."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": r.name, "slug": r.slug, "description": r.description,
|
||||||
|
"markers": list(r.markers or []), "order_index": r.order_index,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _project_platform_rows(rows, platform_slugs: dict[int, str]) -> list[dict]:
|
||||||
|
"""A project's platforms, by SLUG. A `rejected` row travels too: it is
|
||||||
|
someone's "no", and without it the first refresh detects it straight back."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"project_id": r.project_id,
|
||||||
|
"platform_slug": platform_slugs.get(r.platform_id or 0),
|
||||||
|
"state": r.state,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _family_idea_rows(rows) -> list[dict]:
|
||||||
|
"""A note's family state (v25). note_id and topic_id are SOURCE ids."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"note_id": r.note_id, "status": r.status,
|
||||||
|
"applies_when": r.applies_when, "canon_version": r.canon_version,
|
||||||
|
"topic_id": r.topic_id,
|
||||||
|
"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 _family_idea_platform_rows(rows, platform_slugs: dict[int, str]) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"note_id": r.note_id,
|
||||||
|
"platform_slug": platform_slugs.get(r.platform_id or 0),
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _family_idea_reference_rows(rows) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"idea_id": r.idea_id, "snippet_id": r.snippet_id,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _family_adoption_rows(rows) -> list[dict]:
|
||||||
|
"""The adoption ledger (v25). Project, idea and owed task are SOURCE ids."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"project_id": r.project_id, "idea_id": r.idea_id,
|
||||||
|
"status": r.status, "reason": r.reason,
|
||||||
|
"canon_version": r.canon_version,
|
||||||
|
"assessed_at": r.assessed_at.isoformat() if r.assessed_at else None,
|
||||||
|
"decided_via": r.decided_via, "owed_task_id": r.owed_task_id,
|
||||||
|
"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 _family_decision_rows(rows) -> list[dict]:
|
||||||
|
"""The decision log (v25), oldest first. The source `id` travels because
|
||||||
|
`precedent_ids` point at it; the restore rebuilds that map as it goes."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id, "idea_id": r.idea_id, "project_id": r.project_id,
|
||||||
|
"action": r.action, "reason": r.reason,
|
||||||
|
"before": r.before, "after": r.after, "evidence": r.evidence,
|
||||||
|
"precedent_ids": list(r.precedent_ids or []),
|
||||||
|
"decided_via": r.decided_via, "user_id": r.user_id,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _rule_rows(rows) -> list[dict]:
|
def _rule_rows(rows) -> list[dict]:
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -923,6 +1054,24 @@ async def export_full_backup() -> dict:
|
|||||||
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
|
||||||
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
topics = (await session.execute(select(RulebookTopic))).scalars().all()
|
||||||
rules = (await session.execute(select(Rule))).scalars().all()
|
rules = (await session.execute(select(Rule))).scalars().all()
|
||||||
|
platforms = (await session.execute(
|
||||||
|
select(Platform).where(Platform.deleted_at.is_(None))
|
||||||
|
.order_by(Platform.order_index)
|
||||||
|
)).scalars().all()
|
||||||
|
project_platforms = (await session.execute(select(ProjectPlatform))).scalars().all()
|
||||||
|
family_ideas = (await session.execute(select(FamilyIdea))).scalars().all()
|
||||||
|
family_idea_platforms = (await session.execute(
|
||||||
|
select(FamilyIdeaPlatform)
|
||||||
|
)).scalars().all()
|
||||||
|
family_idea_references = (await session.execute(
|
||||||
|
select(FamilyIdeaReference)
|
||||||
|
)).scalars().all()
|
||||||
|
family_adoptions = (await session.execute(select(FamilyAdoption))).scalars().all()
|
||||||
|
# Oldest first: a precedent is always an earlier decision, so a restore
|
||||||
|
# in this order has every precedent mapped before anything cites it.
|
||||||
|
family_decisions = (await session.execute(
|
||||||
|
select(FamilyDecision).order_by(FamilyDecision.id)
|
||||||
|
)).scalars().all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -970,6 +1119,28 @@ async def export_full_backup() -> dict:
|
|||||||
"code_shapes": _code_shape_rows(code_shapes),
|
"code_shapes": _code_shape_rows(code_shapes),
|
||||||
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
||||||
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
|
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
|
||||||
|
**_family_sections(
|
||||||
|
platforms, project_platforms, family_ideas, family_idea_platforms,
|
||||||
|
family_idea_references, family_adoptions, family_decisions,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _family_sections(
|
||||||
|
platforms, project_platforms, ideas, idea_platforms, references,
|
||||||
|
adoptions, decisions,
|
||||||
|
) -> dict:
|
||||||
|
"""The v25 payload sections, shared by both export scopes so the two
|
||||||
|
cannot serialise family canon differently."""
|
||||||
|
slugs = {p.id: p.slug for p in platforms}
|
||||||
|
return {
|
||||||
|
"platforms": _platform_rows(platforms),
|
||||||
|
"project_platforms": _project_platform_rows(project_platforms, slugs),
|
||||||
|
"family_ideas": _family_idea_rows(ideas),
|
||||||
|
"family_idea_platforms": _family_idea_platform_rows(idea_platforms, slugs),
|
||||||
|
"family_idea_references": _family_idea_reference_rows(references),
|
||||||
|
"family_adoptions": _family_adoption_rows(adoptions),
|
||||||
|
"family_decisions": _family_decision_rows(decisions),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1145,6 +1316,47 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
lesson_no_rule = (await session.execute(
|
lesson_no_rule = (await session.execute(
|
||||||
select(LessonNoRule).where(LessonNoRule.lesson_id.in_(note_ids))
|
select(LessonNoRule).where(LessonNoRule.lesson_id.in_(note_ids))
|
||||||
)).scalars().all() if note_ids else []
|
)).scalars().all() if note_ids else []
|
||||||
|
# Family canon (v25). The catalog is global and taken whole, for the
|
||||||
|
# canonical_systems reason. Everything else is scoped so that BOTH
|
||||||
|
# ends of every row restore: an idea is this user's note, a ledger row
|
||||||
|
# needs this user's project AND idea, a reference needs both notes.
|
||||||
|
platforms = (await session.execute(
|
||||||
|
select(Platform).where(Platform.deleted_at.is_(None))
|
||||||
|
.order_by(Platform.order_index)
|
||||||
|
)).scalars().all()
|
||||||
|
project_platforms = (await session.execute(
|
||||||
|
select(ProjectPlatform).where(ProjectPlatform.project_id.in_(project_ids))
|
||||||
|
)).scalars().all() if project_ids else []
|
||||||
|
family_ideas = (await session.execute(
|
||||||
|
select(FamilyIdea).where(FamilyIdea.note_id.in_(note_ids))
|
||||||
|
)).scalars().all() if note_ids else []
|
||||||
|
idea_ids = [i.note_id for i in family_ideas]
|
||||||
|
family_idea_platforms = (await session.execute(
|
||||||
|
select(FamilyIdeaPlatform).where(FamilyIdeaPlatform.note_id.in_(idea_ids))
|
||||||
|
)).scalars().all() if idea_ids else []
|
||||||
|
family_idea_references = (await session.execute(
|
||||||
|
select(FamilyIdeaReference).where(
|
||||||
|
FamilyIdeaReference.idea_id.in_(idea_ids),
|
||||||
|
FamilyIdeaReference.snippet_id.in_(note_ids),
|
||||||
|
)
|
||||||
|
)).scalars().all() if idea_ids else []
|
||||||
|
family_adoptions = (await session.execute(
|
||||||
|
select(FamilyAdoption).where(
|
||||||
|
FamilyAdoption.idea_id.in_(idea_ids),
|
||||||
|
FamilyAdoption.project_id.in_(project_ids),
|
||||||
|
)
|
||||||
|
)).scalars().all() if (idea_ids and project_ids) else []
|
||||||
|
# A decision about the idea itself has no project; one about a ledger
|
||||||
|
# row needs that row's project in this export too.
|
||||||
|
family_decisions = (await session.execute(
|
||||||
|
select(FamilyDecision).where(
|
||||||
|
FamilyDecision.idea_id.in_(idea_ids),
|
||||||
|
or_(
|
||||||
|
FamilyDecision.project_id.is_(None),
|
||||||
|
FamilyDecision.project_id.in_(project_ids or [0]),
|
||||||
|
),
|
||||||
|
).order_by(FamilyDecision.id)
|
||||||
|
)).scalars().all() if idea_ids else []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": BACKUP_VERSION,
|
"version": BACKUP_VERSION,
|
||||||
@@ -1194,6 +1406,10 @@ async def export_user_backup(user_id: int) -> dict:
|
|||||||
"code_shapes": _code_shape_rows(code_shapes),
|
"code_shapes": _code_shape_rows(code_shapes),
|
||||||
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
"code_shape_events": _code_shape_event_rows(code_shape_events),
|
||||||
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
|
"code_shape_uses": _code_shape_use_rows(code_shape_uses),
|
||||||
|
**_family_sections(
|
||||||
|
platforms, project_platforms, family_ideas, family_idea_platforms,
|
||||||
|
family_idea_references, family_adoptions, family_decisions,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1240,6 +1456,7 @@ class _Maps:
|
|||||||
__slots__ = (
|
__slots__ = (
|
||||||
"users", "projects", "milestones", "notes", "rulebooks", "topics",
|
"users", "projects", "milestones", "notes", "rulebooks", "topics",
|
||||||
"rules", "systems", "design_systems", "shapes", "canonical_by_slug",
|
"rules", "systems", "design_systems", "shapes", "canonical_by_slug",
|
||||||
|
"platform_by_slug", "decisions",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -1254,6 +1471,8 @@ class _Maps:
|
|||||||
self.design_systems: dict[int, int] = {}
|
self.design_systems: dict[int, int] = {}
|
||||||
self.shapes: dict[int, int] = {}
|
self.shapes: dict[int, int] = {}
|
||||||
self.canonical_by_slug: dict[str, int] = {}
|
self.canonical_by_slug: dict[str, int] = {}
|
||||||
|
self.platform_by_slug: dict[str, int] = {}
|
||||||
|
self.decisions: dict[int, int] = {}
|
||||||
|
|
||||||
|
|
||||||
def _build_user(row: dict, maps: _Maps) -> User:
|
def _build_user(row: dict, maps: _Maps) -> User:
|
||||||
@@ -1592,6 +1811,127 @@ def _build_rule_moment_judgment(row: dict, maps: _Maps) -> RuleMomentJudgment |
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_platform(row: dict, maps: _Maps) -> Platform | None:
|
||||||
|
"""Matched on SLUG, like a canonical system: this install seeded the
|
||||||
|
standard platforms from its migrations, so the common case creates nothing
|
||||||
|
and only an entry added on the source instance is built."""
|
||||||
|
slug = row.get("slug") or ""
|
||||||
|
if not slug or slug in maps.platform_by_slug:
|
||||||
|
return None
|
||||||
|
return Platform(
|
||||||
|
name=row.get("name", ""),
|
||||||
|
slug=slug,
|
||||||
|
description=row.get("description"),
|
||||||
|
markers=list(row.get("markers") or []),
|
||||||
|
order_index=row.get("order_index", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_project_platform(row: dict, maps: _Maps) -> ProjectPlatform | None:
|
||||||
|
"""Skipped unless both the project and the platform resolve."""
|
||||||
|
project = maps.projects.get(row.get("project_id", 0))
|
||||||
|
platform = maps.platform_by_slug.get(row.get("platform_slug") or "")
|
||||||
|
if project is None or platform is None:
|
||||||
|
return None
|
||||||
|
return ProjectPlatform(
|
||||||
|
project_id=project,
|
||||||
|
platform_id=platform,
|
||||||
|
state=row.get("state") or "declared",
|
||||||
|
created_at=_dt(row.get("created_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_family_idea(row: dict, maps: _Maps) -> FamilyIdea | None:
|
||||||
|
"""Skipped when its note did not restore — the state is that note's. The
|
||||||
|
topic DEGRADES to None: a standard whose rule topic did not come across is
|
||||||
|
still a standard, only without its binding half."""
|
||||||
|
note = maps.notes.get(row.get("note_id", 0))
|
||||||
|
if note is None:
|
||||||
|
return None
|
||||||
|
return FamilyIdea(
|
||||||
|
note_id=note,
|
||||||
|
status=row.get("status") or "candidate",
|
||||||
|
applies_when=row.get("applies_when"),
|
||||||
|
canon_version=row.get("canon_version") or 1,
|
||||||
|
topic_id=maps.topics.get(row.get("topic_id") or 0),
|
||||||
|
created_at=_dt(row.get("created_at")),
|
||||||
|
updated_at=_dt(row.get("updated_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_family_idea_platform(row: dict, maps: _Maps) -> FamilyIdeaPlatform | None:
|
||||||
|
note = maps.notes.get(row.get("note_id", 0))
|
||||||
|
platform = maps.platform_by_slug.get(row.get("platform_slug") or "")
|
||||||
|
if note is None or platform is None:
|
||||||
|
return None
|
||||||
|
return FamilyIdeaPlatform(
|
||||||
|
note_id=note, platform_id=platform, created_at=_dt(row.get("created_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_family_idea_reference(row: dict, maps: _Maps) -> FamilyIdeaReference | None:
|
||||||
|
idea = maps.notes.get(row.get("idea_id", 0))
|
||||||
|
snippet = maps.notes.get(row.get("snippet_id", 0))
|
||||||
|
if idea is None or snippet is None:
|
||||||
|
return None
|
||||||
|
return FamilyIdeaReference(
|
||||||
|
idea_id=idea, snippet_id=snippet, created_at=_dt(row.get("created_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_family_adoption(row: dict, maps: _Maps) -> FamilyAdoption | None:
|
||||||
|
"""Both the project and the idea must map — the row IS that pair. The owed
|
||||||
|
task degrades to None: the answer still stands without its task."""
|
||||||
|
project = maps.projects.get(row.get("project_id", 0))
|
||||||
|
idea = maps.notes.get(row.get("idea_id", 0))
|
||||||
|
if project is None or idea is None:
|
||||||
|
return None
|
||||||
|
return FamilyAdoption(
|
||||||
|
project_id=project,
|
||||||
|
idea_id=idea,
|
||||||
|
status=row.get("status") or "unassessed",
|
||||||
|
reason=row.get("reason"),
|
||||||
|
canon_version=row.get("canon_version"),
|
||||||
|
assessed_at=_dt_or_none(row.get("assessed_at")),
|
||||||
|
decided_via=row.get("decided_via"),
|
||||||
|
owed_task_id=maps.notes.get(row.get("owed_task_id") or 0),
|
||||||
|
created_at=_dt(row.get("created_at")),
|
||||||
|
updated_at=_dt(row.get("updated_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_family_decision(row: dict, maps: _Maps) -> FamilyDecision | None:
|
||||||
|
"""Skipped when its idea did not restore, or when it is about a project
|
||||||
|
that did not — an assessment without its project says nothing. The acting
|
||||||
|
user degrades to None. Precedents are remapped through the decision map
|
||||||
|
and a precedent that did not restore is dropped from the list rather than
|
||||||
|
left pointing at whatever took its number."""
|
||||||
|
idea = maps.notes.get(row.get("idea_id", 0))
|
||||||
|
if idea is None:
|
||||||
|
return None
|
||||||
|
project = None
|
||||||
|
if row.get("project_id") is not None:
|
||||||
|
project = maps.projects.get(row["project_id"])
|
||||||
|
if project is None:
|
||||||
|
return None
|
||||||
|
return FamilyDecision(
|
||||||
|
idea_id=idea,
|
||||||
|
project_id=project,
|
||||||
|
action=row.get("action") or "assess",
|
||||||
|
reason=row.get("reason") or "",
|
||||||
|
before=row.get("before"),
|
||||||
|
after=row.get("after"),
|
||||||
|
evidence=row.get("evidence"),
|
||||||
|
precedent_ids=[
|
||||||
|
maps.decisions[p] for p in (row.get("precedent_ids") or [])
|
||||||
|
if p in maps.decisions
|
||||||
|
],
|
||||||
|
decided_via=row.get("decided_via") or "agent",
|
||||||
|
user_id=maps.users.get(row.get("user_id") or 0),
|
||||||
|
created_at=_dt(row.get("created_at")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None:
|
def _build_rule_version(row: dict, maps: _Maps) -> RuleVersion | None:
|
||||||
rid = maps.rules.get(row.get("rule_id", 0))
|
rid = maps.rules.get(row.get("rule_id", 0))
|
||||||
if rid is None:
|
if rid is None:
|
||||||
@@ -2010,6 +2350,9 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
"rule_relations": 0, "rule_versions": 0,
|
"rule_relations": 0, "rule_versions": 0,
|
||||||
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
|
"retrieval_tuning_events": 0, "lesson_rule_links": 0,
|
||||||
"lesson_no_rule": 0, "moment_mappings": 0,
|
"lesson_no_rule": 0, "moment_mappings": 0,
|
||||||
|
"platforms": 0, "project_platforms": 0, "family_ideas": 0,
|
||||||
|
"family_idea_platforms": 0, "family_idea_references": 0,
|
||||||
|
"family_adoptions": 0, "family_decisions": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
@@ -2250,6 +2593,66 @@ async def _restore_v2(data: dict) -> dict:
|
|||||||
session.add(answer)
|
session.add(answer)
|
||||||
stats["lesson_no_rule"] += 1
|
stats["lesson_no_rule"] += 1
|
||||||
|
|
||||||
|
# Family canon (v25). Here because it needs projects, notes and topics
|
||||||
|
# all mapped. The platform catalog first, matched on slug and seeded
|
||||||
|
# from what this install already has — the 14c shape.
|
||||||
|
existing_platforms = (await session.execute(
|
||||||
|
select(Platform).where(Platform.deleted_at.is_(None))
|
||||||
|
)).scalars().all()
|
||||||
|
for platform in existing_platforms:
|
||||||
|
maps.platform_by_slug[platform.slug] = platform.id
|
||||||
|
for p_data in data.get("platforms", []):
|
||||||
|
platform = _build_platform(p_data, maps)
|
||||||
|
if platform is None:
|
||||||
|
continue
|
||||||
|
session.add(platform)
|
||||||
|
await session.flush()
|
||||||
|
maps.platform_by_slug[platform.slug] = platform.id
|
||||||
|
stats["platforms"] += 1
|
||||||
|
for pp in data.get("project_platforms", []):
|
||||||
|
membership = _build_project_platform(pp, maps)
|
||||||
|
if membership is None:
|
||||||
|
continue
|
||||||
|
session.add(membership)
|
||||||
|
stats["project_platforms"] += 1
|
||||||
|
for fi in data.get("family_ideas", []):
|
||||||
|
idea = _build_family_idea(fi, maps)
|
||||||
|
if idea is None:
|
||||||
|
continue
|
||||||
|
session.add(idea)
|
||||||
|
stats["family_ideas"] += 1
|
||||||
|
# The ideas must exist before anything foreign-keys them.
|
||||||
|
await session.flush()
|
||||||
|
for fp in data.get("family_idea_platforms", []):
|
||||||
|
scope = _build_family_idea_platform(fp, maps)
|
||||||
|
if scope is None:
|
||||||
|
continue
|
||||||
|
session.add(scope)
|
||||||
|
stats["family_idea_platforms"] += 1
|
||||||
|
for fr in data.get("family_idea_references", []):
|
||||||
|
ref = _build_family_idea_reference(fr, maps)
|
||||||
|
if ref is None:
|
||||||
|
continue
|
||||||
|
session.add(ref)
|
||||||
|
stats["family_idea_references"] += 1
|
||||||
|
for fa in data.get("family_adoptions", []):
|
||||||
|
adoption = _build_family_adoption(fa, maps)
|
||||||
|
if adoption is None:
|
||||||
|
continue
|
||||||
|
session.add(adoption)
|
||||||
|
stats["family_adoptions"] += 1
|
||||||
|
# Oldest first, flushed one at a time: each decision's new id goes into
|
||||||
|
# the map before a later decision can name it as a precedent.
|
||||||
|
for fd in sorted(data.get("family_decisions", []), key=lambda r: r.get("id") or 0):
|
||||||
|
decision = _build_family_decision(fd, maps)
|
||||||
|
if decision is None:
|
||||||
|
continue
|
||||||
|
session.add(decision)
|
||||||
|
await session.flush()
|
||||||
|
if fd.get("id"):
|
||||||
|
maps.decisions[int(fd["id"])] = decision.id
|
||||||
|
stats["family_decisions"] += 1
|
||||||
|
|
||||||
# A rule's edit history (milestone 323). Must come after the rules
|
# A rule's edit history (milestone 323). Must come after the rules
|
||||||
# themselves — the rule map is only populated above — and both ids are
|
# themselves — the rule map is only populated above — and both ids are
|
||||||
# ids in the SOURCE database, which is #3182's arose_from_id trap.
|
# ids in the SOURCE database, which is #3182's arose_from_id trap.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Family canon's schema — the parts that need no database (milestone 463 step 1).
|
||||||
|
|
||||||
|
The constraints themselves, against real Postgres, are in
|
||||||
|
tests/test_integration_family_canon.py. These pin that the migration and the
|
||||||
|
model agree on every whitelist (rule 36: a value one side accepts and the
|
||||||
|
other refuses only fails at INSERT time, which CI's unit lane never reaches),
|
||||||
|
and that the seed ships nothing an install other than this one would find
|
||||||
|
foreign (rule 115).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scribe.models import family
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _migration():
|
||||||
|
path = ROOT / "alembic" / "versions" / "0120_family_canon.py"
|
||||||
|
spec = importlib.util.spec_from_file_location("m0120", path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_migration_checks_and_the_model_agree():
|
||||||
|
m = _migration()
|
||||||
|
assert tuple(m._MEMBERSHIP) == family.MEMBERSHIP_STATES
|
||||||
|
assert tuple(m._IDEA_STATUSES) == family.IDEA_STATUSES
|
||||||
|
assert tuple(m._ADOPTION_STATUSES) == family.ADOPTION_STATUSES
|
||||||
|
assert tuple(m._REASONED) == family.REASONED_STATUSES
|
||||||
|
assert tuple(m._ACTIONS) == family.DECISION_ACTIONS
|
||||||
|
assert tuple(m._DECIDERS) == family.DECIDERS
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_departures_that_need_a_reason_are_adoption_states():
|
||||||
|
assert set(family.REASONED_STATUSES) <= set(family.ADOPTION_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_seed_is_generic_and_unique():
|
||||||
|
seed = _migration()._SEED
|
||||||
|
slugs = [slug for _, slug, _, _ in seed]
|
||||||
|
assert len(slugs) == len(set(slugs)), "a duplicate slug breaks the live-unique index"
|
||||||
|
for name, slug, description, markers in seed:
|
||||||
|
assert name and slug and description
|
||||||
|
assert slug == slug.lower() and " " not in slug
|
||||||
|
# A list, possibly empty — never None. Detection reads it directly.
|
||||||
|
assert isinstance(markers, list)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_column_defaults_match_the_first_state_of_each_lifecycle():
|
||||||
|
"""A row created with no state given starts where its lifecycle starts.
|
||||||
|
An idea is a candidate until promoted; an answer is unassessed until
|
||||||
|
someone assesses it — never silently `adopted`."""
|
||||||
|
assert family.FamilyIdea.__table__.c.status.server_default.arg == "candidate"
|
||||||
|
assert family.FamilyAdoption.__table__.c.status.server_default.arg == "unassessed"
|
||||||
|
assert family.ProjectPlatform.__table__.c.state.server_default.arg == "declared"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_canon_ideas_scope_lives_on_the_idea_and_nowhere_else():
|
||||||
|
"""One scope source. A rule topic is linked FROM the idea and carries no
|
||||||
|
platforms of its own, so the two cannot disagree about who a standard
|
||||||
|
reaches (#4221's two-sources-of-truth shape)."""
|
||||||
|
from scribe.models.rulebook import RulebookTopic
|
||||||
|
|
||||||
|
assert "topic_id" in family.FamilyIdea.__table__.c
|
||||||
|
assert not {c.name for c in RulebookTopic.__table__.c} & {"platform_id", "platform_ids"}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""Real-Postgres checks for family canon's schema (milestone 463 step 1).
|
||||||
|
|
||||||
|
Two kinds of claim live here, and both need a database:
|
||||||
|
|
||||||
|
1. THE CONSTRAINTS. The agent assesses and promotes with no approval step, so
|
||||||
|
the few things that must always hold are held by the schema rather than by
|
||||||
|
prose a session might skip: a canon idea states when it applies, a variant
|
||||||
|
or an exemption says why, a decision has a reason. Each is shown refusing
|
||||||
|
the bad row, and each beside a good row so the refusal is not a fixture
|
||||||
|
that fails for some other reason.
|
||||||
|
|
||||||
|
2. THE RESTORE REMAPS. Two seams can come back plausible and wrong:
|
||||||
|
- platforms travel by SLUG and must land on the destination's own seeded
|
||||||
|
rows, not create duplicates of them;
|
||||||
|
- `precedent_ids` is a list of ids INSIDE JSON. A restore that copied it
|
||||||
|
raw would leave each decision pointing at whatever took the old number —
|
||||||
|
populated, plausible, and about the wrong decision.
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from scribe.models import async_session
|
||||||
|
from scribe.models.family import (
|
||||||
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
|
||||||
|
FamilyIdeaReference, Platform, ProjectPlatform,
|
||||||
|
)
|
||||||
|
from scribe.models.note import Note
|
||||||
|
from scribe.models.project import Project
|
||||||
|
from scribe.models.user import User
|
||||||
|
from scribe.services import backup
|
||||||
|
from tests.helpers import ensure_user
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
||||||
|
|
||||||
|
OWNER_USERNAME = "family_canon_owner"
|
||||||
|
RESTORED_USERNAME = "family_canon_restored"
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge(username: str) -> None:
|
||||||
|
"""user -> project / note is ON DELETE CASCADE, and every family table
|
||||||
|
cascades from a project or a note, so dropping the user's rows clears
|
||||||
|
everything this file made. Platforms are global and never created here
|
||||||
|
beyond the migration's seed, so there is nothing of theirs to clear."""
|
||||||
|
async with async_session() as s:
|
||||||
|
for user in (await s.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
)).scalars().all():
|
||||||
|
for note in (await s.execute(
|
||||||
|
select(Note).where(Note.user_id == user.id)
|
||||||
|
)).scalars().all():
|
||||||
|
await s.delete(note)
|
||||||
|
for project in (await s.execute(
|
||||||
|
select(Project).where(Project.user_id == user.id)
|
||||||
|
)).scalars().all():
|
||||||
|
await s.delete(project)
|
||||||
|
if username == RESTORED_USERNAME:
|
||||||
|
await s.delete(user)
|
||||||
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(autouse=True)
|
||||||
|
async def _no_leftovers():
|
||||||
|
"""SETUP ONLY — a database call after a `yield` in an autouse fixture
|
||||||
|
orphans a pooled connection (see the backup round-trip siblings)."""
|
||||||
|
await _purge(RESTORED_USERNAME)
|
||||||
|
await _purge(OWNER_USERNAME)
|
||||||
|
|
||||||
|
|
||||||
|
async def _platform(slug: str) -> Platform:
|
||||||
|
async with async_session() as s:
|
||||||
|
return (await s.execute(
|
||||||
|
select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
|
||||||
|
)).scalars().one()
|
||||||
|
|
||||||
|
|
||||||
|
async def _owner_project_and_note() -> tuple[int, int, int]:
|
||||||
|
async with async_session() as s:
|
||||||
|
owner = await ensure_user(s, OWNER_USERNAME)
|
||||||
|
project = Project(user_id=owner.id, title="an app on a platform")
|
||||||
|
note = Note(user_id=owner.id, title="an idea", body="the idea")
|
||||||
|
s.add_all([project, note])
|
||||||
|
await s.commit()
|
||||||
|
return owner.id, project.id, note.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _refused(*rows) -> bool:
|
||||||
|
async with async_session() as s:
|
||||||
|
s.add_all(list(rows))
|
||||||
|
try:
|
||||||
|
await s.commit()
|
||||||
|
except IntegrityError:
|
||||||
|
await s.rollback()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# --- the seed ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_the_migration_seeds_generic_platforms_with_their_markers():
|
||||||
|
android = await _platform("android-app")
|
||||||
|
assert "AndroidManifest.xml" in android.markers
|
||||||
|
# Declare-only platforms carry an empty list, never NULL: detection reads
|
||||||
|
# the list and must not have to special-case a missing one.
|
||||||
|
assert (await _platform("postgresql")).markers == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- the constraints ----------------------------------------------------------
|
||||||
|
|
||||||
|
async def test_a_canon_idea_must_say_when_it_applies():
|
||||||
|
_, _, note_id = await _owner_project_and_note()
|
||||||
|
assert await _refused(FamilyIdea(note_id=note_id, status="canon", applies_when=" "))
|
||||||
|
# A candidate may be vague — that is what a candidate is.
|
||||||
|
assert not await _refused(FamilyIdea(note_id=note_id, status="candidate"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_variant_or_an_exemption_must_say_why():
|
||||||
|
_, project_id, note_id = await _owner_project_and_note()
|
||||||
|
assert not await _refused(FamilyIdea(
|
||||||
|
note_id=note_id, status="canon",
|
||||||
|
applies_when="any app that installs its own updates",
|
||||||
|
))
|
||||||
|
for status in ("variant", "exempt"):
|
||||||
|
assert await _refused(FamilyAdoption(
|
||||||
|
project_id=project_id, idea_id=note_id, status=status, reason=" ",
|
||||||
|
)), f"{status} with a blank reason was accepted"
|
||||||
|
# Owed and adopted need no reason: neither is a departure.
|
||||||
|
assert not await _refused(FamilyAdoption(
|
||||||
|
project_id=project_id, idea_id=note_id, status="owed", decided_via="agent",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_unknown_state_is_refused():
|
||||||
|
_, project_id, note_id = await _owner_project_and_note()
|
||||||
|
assert not await _refused(FamilyIdea(note_id=note_id))
|
||||||
|
android = await _platform("android-app")
|
||||||
|
assert await _refused(ProjectPlatform(
|
||||||
|
project_id=project_id, platform_id=android.id, state="maybe",
|
||||||
|
))
|
||||||
|
assert await _refused(FamilyAdoption(
|
||||||
|
project_id=project_id, idea_id=note_id, status="declined",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_decision_must_carry_a_reason():
|
||||||
|
_, _, note_id = await _owner_project_and_note()
|
||||||
|
assert not await _refused(FamilyIdea(note_id=note_id))
|
||||||
|
assert await _refused(FamilyDecision(idea_id=note_id, action="propose", reason=" "))
|
||||||
|
assert not await _refused(FamilyDecision(
|
||||||
|
idea_id=note_id, action="propose", reason="built twice, in two projects",
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# --- the restore ------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def source():
|
||||||
|
"""An idea with everything family canon can hang off it: a platform scope,
|
||||||
|
a reference snippet, a project that declares one platform and rejects
|
||||||
|
another, a variant answer, and two decisions where the second cites the
|
||||||
|
first as its precedent.
|
||||||
|
|
||||||
|
`decoy` exists so the target database has an ADDITIONAL decision whose id
|
||||||
|
could collide with a source precedent id — without it, a raw-copied
|
||||||
|
precedent list could happen to point at nothing and read as dropped,
|
||||||
|
rather than as pointing at the wrong decision.
|
||||||
|
"""
|
||||||
|
owner_id, project_id, idea_id = await _owner_project_and_note()
|
||||||
|
android = await _platform("android-app")
|
||||||
|
container = await _platform("container-image")
|
||||||
|
async with async_session() as s:
|
||||||
|
snippet = Note(user_id=owner_id, title="the reference", body="code",
|
||||||
|
note_type="snippet")
|
||||||
|
s.add(snippet)
|
||||||
|
s.add(FamilyIdea(note_id=idea_id, status="canon", canon_version=2,
|
||||||
|
applies_when="any app that installs its own updates"))
|
||||||
|
await s.flush()
|
||||||
|
s.add_all([
|
||||||
|
FamilyIdeaPlatform(note_id=idea_id, platform_id=android.id),
|
||||||
|
FamilyIdeaReference(idea_id=idea_id, snippet_id=snippet.id),
|
||||||
|
ProjectPlatform(project_id=project_id, platform_id=android.id, state="declared"),
|
||||||
|
ProjectPlatform(project_id=project_id, platform_id=container.id, state="rejected"),
|
||||||
|
FamilyAdoption(project_id=project_id, idea_id=idea_id, status="variant",
|
||||||
|
reason="updates arrive through the store, not in-app",
|
||||||
|
canon_version=1, decided_via="agent"),
|
||||||
|
])
|
||||||
|
first = FamilyDecision(idea_id=idea_id, action="promote",
|
||||||
|
reason="proven in CI, stated in platform terms",
|
||||||
|
after={"status": "canon"})
|
||||||
|
s.add(first)
|
||||||
|
await s.flush()
|
||||||
|
second = FamilyDecision(idea_id=idea_id, project_id=project_id, action="assess",
|
||||||
|
reason="store-distributed, as in the promotion's terms",
|
||||||
|
precedent_ids=[first.id], decided_via="agent")
|
||||||
|
s.add(second)
|
||||||
|
await s.commit()
|
||||||
|
snippet_id = snippet.id
|
||||||
|
|
||||||
|
async with async_session() as s:
|
||||||
|
notes = (await s.execute(
|
||||||
|
select(Note).where(Note.id.in_([idea_id, snippet_id])).order_by(Note.id)
|
||||||
|
)).scalars().all()
|
||||||
|
payload = {
|
||||||
|
"version": backup.BACKUP_VERSION,
|
||||||
|
"users": backup._user_rows([await s.get(User, owner_id)]),
|
||||||
|
"projects": backup._project_rows([await s.get(Project, project_id)]),
|
||||||
|
"notes": backup._note_rows(notes),
|
||||||
|
**backup._family_sections(
|
||||||
|
(await s.execute(select(Platform))).scalars().all(),
|
||||||
|
(await s.execute(select(ProjectPlatform).where(
|
||||||
|
ProjectPlatform.project_id == project_id))).scalars().all(),
|
||||||
|
[await s.get(FamilyIdea, idea_id)],
|
||||||
|
(await s.execute(select(FamilyIdeaPlatform).where(
|
||||||
|
FamilyIdeaPlatform.note_id == idea_id))).scalars().all(),
|
||||||
|
(await s.execute(select(FamilyIdeaReference).where(
|
||||||
|
FamilyIdeaReference.idea_id == idea_id))).scalars().all(),
|
||||||
|
(await s.execute(select(FamilyAdoption).where(
|
||||||
|
FamilyAdoption.idea_id == idea_id))).scalars().all(),
|
||||||
|
(await s.execute(select(FamilyDecision).where(
|
||||||
|
FamilyDecision.idea_id == idea_id).order_by(FamilyDecision.id)
|
||||||
|
)).scalars().all(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
payload["users"][0]["username"] = RESTORED_USERNAME
|
||||||
|
return {"payload": payload}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def restored(source):
|
||||||
|
async with async_session() as s:
|
||||||
|
before = len((await s.execute(select(Platform))).scalars().all())
|
||||||
|
stats = await backup.restore_full_backup(source["payload"])
|
||||||
|
async with async_session() as s:
|
||||||
|
user = (await s.execute(
|
||||||
|
select(User).where(User.username == RESTORED_USERNAME)
|
||||||
|
)).scalars().one()
|
||||||
|
project = (await s.execute(
|
||||||
|
select(Project).where(Project.user_id == user.id)
|
||||||
|
)).scalars().one()
|
||||||
|
notes = (await s.execute(
|
||||||
|
select(Note).where(Note.user_id == user.id)
|
||||||
|
)).scalars().all()
|
||||||
|
note_ids = [n.id for n in notes]
|
||||||
|
return {
|
||||||
|
"stats": stats,
|
||||||
|
"platforms_before": before,
|
||||||
|
"platforms_after": len((await s.execute(select(Platform))).scalars().all()),
|
||||||
|
"project": project,
|
||||||
|
"notes": {n.note_type: n for n in notes},
|
||||||
|
"idea": (await s.execute(
|
||||||
|
select(FamilyIdea).where(FamilyIdea.note_id.in_(note_ids))
|
||||||
|
)).scalars().one(),
|
||||||
|
"memberships": (await s.execute(
|
||||||
|
select(ProjectPlatform).where(ProjectPlatform.project_id == project.id)
|
||||||
|
)).scalars().all(),
|
||||||
|
"adoption": (await s.execute(
|
||||||
|
select(FamilyAdoption).where(FamilyAdoption.project_id == project.id)
|
||||||
|
)).scalars().one(),
|
||||||
|
"decisions": (await s.execute(
|
||||||
|
select(FamilyDecision).where(FamilyDecision.idea_id.in_(note_ids))
|
||||||
|
.order_by(FamilyDecision.id)
|
||||||
|
)).scalars().all(),
|
||||||
|
"references": (await s.execute(
|
||||||
|
select(FamilyIdeaReference).where(FamilyIdeaReference.idea_id.in_(note_ids))
|
||||||
|
)).scalars().all(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_platforms_match_by_slug_and_create_nothing(restored):
|
||||||
|
assert restored["stats"]["platforms"] == 0
|
||||||
|
assert restored["platforms_after"] == restored["platforms_before"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_rejected_platform_survives_so_detection_cannot_re_add_it(restored):
|
||||||
|
states = {m.state for m in restored["memberships"]}
|
||||||
|
assert states == {"declared", "rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_idea_and_its_answer_come_back_whole(restored):
|
||||||
|
idea = restored["idea"]
|
||||||
|
assert idea.status == "canon"
|
||||||
|
assert idea.canon_version == 2
|
||||||
|
adoption = restored["adoption"]
|
||||||
|
assert adoption.status == "variant"
|
||||||
|
assert adoption.reason == "updates arrive through the store, not in-app"
|
||||||
|
# Assessed against version 1 of a version-2 idea: the derived recheck
|
||||||
|
# signal must survive the trip, which needs both numbers intact.
|
||||||
|
assert adoption.canon_version < idea.canon_version
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_reference_points_at_the_RESTORED_snippet(restored):
|
||||||
|
[ref] = restored["references"]
|
||||||
|
assert ref.snippet_id == restored["notes"]["snippet"].id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_precedent_is_remapped_to_the_RESTORED_decision(restored):
|
||||||
|
"""The trap: precedent_ids is a list of ids inside JSON. Copied raw, it
|
||||||
|
would name the SOURCE decision's id — which in a shared database is a
|
||||||
|
real row, the wrong one."""
|
||||||
|
first, second = restored["decisions"]
|
||||||
|
assert first.action == "promote" and second.action == "assess"
|
||||||
|
assert second.precedent_ids == [first.id]
|
||||||
|
assert second.project_id == restored["project"].id
|
||||||
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
|
|||||||
|
|
||||||
(Named for the number it asserted until v10, which is exactly the drift a
|
(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.)"""
|
name-carrying-a-value invites; it now says what it checks.)"""
|
||||||
assert backup.BACKUP_VERSION == 24
|
assert backup.BACKUP_VERSION == 25
|
||||||
|
|
||||||
|
|
||||||
def _exportable_note(**over):
|
def _exportable_note(**over):
|
||||||
@@ -125,11 +125,25 @@ def test_a_repo_binding_carries_the_branch_its_ledger_follows():
|
|||||||
assert row["ref"] == "dev"
|
assert row["ref"] == "dev"
|
||||||
|
|
||||||
|
|
||||||
|
class _AnySlug(dict):
|
||||||
|
"""A slug map in which every platform id resolves. The stand-in's
|
||||||
|
platform_id is an arbitrary integer, and the guard is about which columns
|
||||||
|
travel, not about what a missing platform does — that would make the
|
||||||
|
import builder skip a row the guard needs it to build."""
|
||||||
|
|
||||||
|
def get(self, key, default=None):
|
||||||
|
return "platform-x"
|
||||||
|
|
||||||
|
|
||||||
# The table -> (model, row helper) registry the column guard walks. Kept here
|
# The table -> (model, row helper) registry the column guard walks. Kept here
|
||||||
# rather than in the service because it exists only to be introspected: the
|
# rather than in the service because it exists only to be introspected: the
|
||||||
# product code already knows these pairings by calling them.
|
# product code already knows these pairings by calling them.
|
||||||
def _column_guard_targets():
|
def _column_guard_targets():
|
||||||
from scribe.models.canonical_system import CanonicalSystem
|
from scribe.models.canonical_system import CanonicalSystem
|
||||||
|
from scribe.models.family import (
|
||||||
|
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
|
||||||
|
FamilyIdeaReference, Platform, ProjectPlatform,
|
||||||
|
)
|
||||||
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
from scribe.models.code_shape import CodeShape, CodeShapeEvent, CodeShapeUse
|
||||||
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink
|
from scribe.models.lesson_rule_link import LessonNoRule, LessonRuleLink
|
||||||
from scribe.models.rule_moment_judgment import RuleMomentJudgment
|
from scribe.models.rule_moment_judgment import RuleMomentJudgment
|
||||||
@@ -187,6 +201,18 @@ def _column_guard_targets():
|
|||||||
"code_shapes": (CodeShape, backup._code_shape_rows),
|
"code_shapes": (CodeShape, backup._code_shape_rows),
|
||||||
"code_shape_events": (CodeShapeEvent, backup._code_shape_event_rows),
|
"code_shape_events": (CodeShapeEvent, backup._code_shape_event_rows),
|
||||||
"code_shape_uses": (CodeShapeUse, backup._code_shape_use_rows),
|
"code_shape_uses": (CodeShapeUse, backup._code_shape_use_rows),
|
||||||
|
"platforms": (Platform, backup._platform_rows),
|
||||||
|
"project_platforms": (
|
||||||
|
ProjectPlatform, lambda rows: backup._project_platform_rows(rows, _AnySlug()),
|
||||||
|
),
|
||||||
|
"family_ideas": (FamilyIdea, backup._family_idea_rows),
|
||||||
|
"family_idea_platforms": (
|
||||||
|
FamilyIdeaPlatform,
|
||||||
|
lambda rows: backup._family_idea_platform_rows(rows, _AnySlug()),
|
||||||
|
),
|
||||||
|
"family_idea_references": (FamilyIdeaReference, backup._family_idea_reference_rows),
|
||||||
|
"family_adoptions": (FamilyAdoption, backup._family_adoption_rows),
|
||||||
|
"family_decisions": (FamilyDecision, backup._family_decision_rows),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -306,6 +332,13 @@ def _import_guard_targets():
|
|||||||
"code_shapes": backup._build_code_shape,
|
"code_shapes": backup._build_code_shape,
|
||||||
"code_shape_events": backup._build_code_shape_event,
|
"code_shape_events": backup._build_code_shape_event,
|
||||||
"code_shape_uses": backup._build_code_shape_use,
|
"code_shape_uses": backup._build_code_shape_use,
|
||||||
|
"platforms": backup._build_platform,
|
||||||
|
"project_platforms": backup._build_project_platform,
|
||||||
|
"family_ideas": backup._build_family_idea,
|
||||||
|
"family_idea_platforms": backup._build_family_idea_platform,
|
||||||
|
"family_idea_references": backup._build_family_idea_reference,
|
||||||
|
"family_adoptions": backup._build_family_adoption,
|
||||||
|
"family_decisions": backup._build_family_decision,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
table: (model, helper, builders[table])
|
table: (model, helper, builders[table])
|
||||||
@@ -324,7 +357,8 @@ def _everything_maps(row: dict) -> "backup._Maps":
|
|||||||
maps = backup._Maps()
|
maps = backup._Maps()
|
||||||
ids = {v for v in row.values() if isinstance(v, int)} | {0, 1}
|
ids = {v for v in row.values() if isinstance(v, int)} | {0, 1}
|
||||||
for name in ("users", "projects", "milestones", "notes", "rulebooks",
|
for name in ("users", "projects", "milestones", "notes", "rulebooks",
|
||||||
"topics", "rules", "systems", "design_systems", "shapes"):
|
"topics", "rules", "systems", "design_systems", "shapes",
|
||||||
|
"decisions"):
|
||||||
getattr(maps, name).update({i: i + 1000 for i in ids})
|
getattr(maps, name).update({i: i + 1000 for i in ids})
|
||||||
# `canonical_slug` only, never `slug`: a canonical_systems row is built
|
# `canonical_slug` only, never `slug`: a canonical_systems row is built
|
||||||
# exactly when its slug is NOT already known to the destination, so
|
# exactly when its slug is NOT already known to the destination, so
|
||||||
@@ -332,6 +366,11 @@ def _everything_maps(row: dict) -> "backup._Maps":
|
|||||||
slug = row.get("canonical_slug")
|
slug = row.get("canonical_slug")
|
||||||
if slug:
|
if slug:
|
||||||
maps.canonical_by_slug[slug] = 7
|
maps.canonical_by_slug[slug] = 7
|
||||||
|
# `platform_slug` only, never `slug`, for the same reason: a platforms row
|
||||||
|
# is built exactly when its slug is unknown to the destination.
|
||||||
|
platform_slug = row.get("platform_slug")
|
||||||
|
if platform_slug:
|
||||||
|
maps.platform_by_slug[platform_slug] = 8
|
||||||
return maps
|
return maps
|
||||||
|
|
||||||
|
|
||||||
@@ -556,7 +595,11 @@ async def test_export_full_backup_contains_every_declared_section():
|
|||||||
# v20: whether an area's rulings were read once shown.
|
# v20: whether an area's rulings were read once shown.
|
||||||
"system_usage_events",
|
"system_usage_events",
|
||||||
# v23: proposals and judgments about a rule's moments.
|
# v23: proposals and judgments about a rule's moments.
|
||||||
"rule_moment_judgments"):
|
"rule_moment_judgments",
|
||||||
|
# v25: family canon (milestone 463).
|
||||||
|
"platforms", "project_platforms", "family_ideas",
|
||||||
|
"family_idea_platforms", "family_idea_references",
|
||||||
|
"family_adoptions", "family_decisions"):
|
||||||
assert key in out, f"missing export section: {key}"
|
assert key in out, f"missing export section: {key}"
|
||||||
assert out[key] == []
|
assert out[key] == []
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user