From e68ccc5884b8ff96a663a4b4cb332ce6421efed4 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 09:33:27 -0400
Subject: [PATCH 01/10] feat(family): platforms, family ideas, the adoption
ledger and its decision log (milestone 463 step 1, #4987)
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. This is the storage for that.
- platforms: a global catalog in the canonical_systems shape, seeded with
generic technology names and the file markers step 2's detection reads.
- project_platforms: declared / detected / rejected. A rejected row is kept
so detection cannot re-add what a person said no to.
- family_ideas: a note's family state. No new record type; any note, snippet
or lesson becomes an idea. A canon idea must state when it applies (CHECK).
- family_idea_platforms: the only scope source. A linked rule topic takes its
scope from the idea, so the two cannot disagree.
- family_idea_references: reference implementations, explicit not inferred.
- family_adoptions: one answer per (project, idea). Variant and exempt require
a reason (CHECK). Recheck is derived from the two canon versions, never
stored.
- family_decisions: the append-only log, with a required reason and the
earlier decisions each one followed. The agent decides with no approval
step, so precedent is what keeps its calls consistent.
Backup v25 carries all seven: platforms by slug, precedent ids remapped
through the decision map. Both column guards cover the new tables, and a
real-Postgres test exercises the CHECKs and the restore remaps.
Co-Authored-By: Claude Opus 5.5
---
alembic/versions/0120_family_canon.py | 288 ++++++++++++++++++
src/scribe/models/__init__.py | 6 +
src/scribe/models/family.py | 357 ++++++++++++++++++++++
src/scribe/services/backup.py | 405 ++++++++++++++++++++++++-
tests/test_family_models.py | 69 +++++
tests/test_integration_family_canon.py | 304 +++++++++++++++++++
tests/test_services_backup.py | 49 ++-
7 files changed, 1474 insertions(+), 4 deletions(-)
create mode 100644 alembic/versions/0120_family_canon.py
create mode 100644 src/scribe/models/family.py
create mode 100644 tests/test_family_models.py
create mode 100644 tests/test_integration_family_canon.py
diff --git a/alembic/versions/0120_family_canon.py b/alembic/versions/0120_family_canon.py
new file mode 100644
index 00000000..0a253a51
--- /dev/null
+++ b/alembic/versions/0120_family_canon.py
@@ -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")
diff --git a/src/scribe/models/__init__.py b/src/scribe/models/__init__.py
index 071c188a..01f7ef99 100644
--- a/src/scribe/models/__init__.py
+++ b/src/scribe/models/__init__.py
@@ -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.system import System, RecordSystem # 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,
+)
diff --git a/src/scribe/models/family.py b/src/scribe/models/family.py
new file mode 100644
index 00000000..bdd2d5ae
--- /dev/null
+++ b/src/scribe/models/family.py
@@ -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),
+ }
diff --git a/src/scribe/services/backup.py b/src/scribe/services/backup.py
index 4fd60a97..017144b6 100644
--- a/src/scribe/services/backup.py
+++ b/src/scribe/services/backup.py
@@ -17,6 +17,10 @@ from scribe.models.system_usage import SystemUsageEvent
from scribe.models.moment_mapping import MomentMapping
from scribe.models.retrieval_tuning import RetrievalTuningEvent
from scribe.models.canonical_system import CanonicalSystem
+from scribe.models.family import (
+ FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform,
+ FamilyIdeaReference, Platform, ProjectPlatform,
+)
from scribe.models.rulebook import (
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):
# 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.
+# 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.
-BACKUP_VERSION = 24
+BACKUP_VERSION = 25
# 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
@@ -164,6 +174,12 @@ _BACKED_UP = [
"rule_moments",
# v23 (2026-10): proposals and judgments about those mounts (step 7).
"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
@@ -298,6 +314,19 @@ _COLUMN_EXCLUSIONS: dict[str, set[str]] = {
},
"code_shape_events": 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_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]:
return [
{
@@ -923,6 +1054,24 @@ async def export_full_backup() -> dict:
rulebooks = (await session.execute(select(Rulebook))).scalars().all()
topics = (await session.execute(select(RulebookTopic))).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 {
"version": BACKUP_VERSION,
@@ -970,6 +1119,28 @@ async def export_full_backup() -> dict:
"code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"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(
select(LessonNoRule).where(LessonNoRule.lesson_id.in_(note_ids))
)).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 {
"version": BACKUP_VERSION,
@@ -1194,6 +1406,10 @@ async def export_user_backup(user_id: int) -> dict:
"code_shapes": _code_shape_rows(code_shapes),
"code_shape_events": _code_shape_event_rows(code_shape_events),
"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__ = (
"users", "projects", "milestones", "notes", "rulebooks", "topics",
"rules", "systems", "design_systems", "shapes", "canonical_by_slug",
+ "platform_by_slug", "decisions",
)
def __init__(self) -> None:
@@ -1254,6 +1471,8 @@ class _Maps:
self.design_systems: dict[int, int] = {}
self.shapes: dict[int, 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:
@@ -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:
rid = maps.rules.get(row.get("rule_id", 0))
if rid is None:
@@ -2010,6 +2350,9 @@ async def _restore_v2(data: dict) -> dict:
"rule_relations": 0, "rule_versions": 0,
"retrieval_tuning_events": 0, "lesson_rule_links": 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:
@@ -2250,6 +2593,66 @@ async def _restore_v2(data: dict) -> dict:
session.add(answer)
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
# 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.
diff --git a/tests/test_family_models.py b/tests/test_family_models.py
new file mode 100644
index 00000000..050d17df
--- /dev/null
+++ b/tests/test_family_models.py
@@ -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"}
diff --git a/tests/test_integration_family_canon.py b/tests/test_integration_family_canon.py
new file mode 100644
index 00000000..a51a102b
--- /dev/null
+++ b/tests/test_integration_family_canon.py
@@ -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
diff --git a/tests/test_services_backup.py b/tests/test_services_backup.py
index 5c8e159b..eeefdfed 100644
--- a/tests/test_services_backup.py
+++ b/tests/test_services_backup.py
@@ -27,7 +27,7 @@ def test_backup_version_is_current():
(Named for the number it asserted until v10, which is exactly the drift a
name-carrying-a-value invites; it now says what it checks.)"""
- assert backup.BACKUP_VERSION == 24
+ assert backup.BACKUP_VERSION == 25
def _exportable_note(**over):
@@ -125,11 +125,25 @@ def test_a_repo_binding_carries_the_branch_its_ledger_follows():
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
# rather than in the service because it exists only to be introspected: the
# product code already knows these pairings by calling them.
def _column_guard_targets():
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.lesson_rule_link import LessonNoRule, LessonRuleLink
from scribe.models.rule_moment_judgment import RuleMomentJudgment
@@ -187,6 +201,18 @@ def _column_guard_targets():
"code_shapes": (CodeShape, backup._code_shape_rows),
"code_shape_events": (CodeShapeEvent, backup._code_shape_event_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_shape_events": backup._build_code_shape_event,
"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 {
table: (model, helper, builders[table])
@@ -324,7 +357,8 @@ def _everything_maps(row: dict) -> "backup._Maps":
maps = backup._Maps()
ids = {v for v in row.values() if isinstance(v, int)} | {0, 1}
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})
# `canonical_slug` only, never `slug`: a canonical_systems row is built
# 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")
if slug:
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
@@ -556,7 +595,11 @@ async def test_export_full_backup_contains_every_declared_section():
# v20: whether an area's rulings were read once shown.
"system_usage_events",
# 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 out[key] == []
From 07d2542479cd409198b6115ebe2ac77db46eca35 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 09:51:28 -0400
Subject: [PATCH 02/10] feat(family): platforms declared at inception and
detected from bound repos (milestone 463 step 2, #4988)
A project's platforms decide which family ideas reach it. This step makes membership answerable from every door:
- services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one.
- coverage: the archive scan now carries every path, and the refresh runs detection fail-open.
- inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected.
- MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms.
- REST: /api/platforms (admin writes) and /api/projects//platforms.
- UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/api/inception.ts | 17 +-
frontend/src/api/platforms.ts | 77 +++++
frontend/src/components/InceptionCard.vue | 73 +++-
frontend/src/components/ProjectFamilyTab.vue | 200 +++++++++++
frontend/src/stores/platforms.ts | 48 +++
frontend/src/views/ProjectView.vue | 18 +-
frontend/src/views/SettingsView.vue | 170 +++++++++-
src/scribe/app.py | 2 +
src/scribe/mcp/server.py | 4 +
src/scribe/mcp/tools/__init__.py | 3 +-
src/scribe/mcp/tools/platforms.py | 78 +++++
src/scribe/mcp/tools/projects.py | 41 ++-
src/scribe/routes/platforms.py | 96 ++++++
src/scribe/services/access.py | 9 +
src/scribe/services/canonical_systems.py | 5 +-
src/scribe/services/coverage.py | 23 +-
src/scribe/services/inception.py | 96 +++++-
src/scribe/services/platforms.py | 335 +++++++++++++++++++
tests/helpers.py | 15 +
tests/test_inception.py | 24 +-
tests/test_integration_inception.py | 10 +-
tests/test_integration_platforms.py | 134 ++++++++
tests/test_mcp_tool_projects.py | 12 +
tests/test_milestone_summary_brief.py | 13 +
tests/test_pattern_coverage.py | 13 +-
tests/test_platforms.py | 145 ++++++++
tests/test_routes_platforms.py | 28 ++
27 files changed, 1638 insertions(+), 51 deletions(-)
create mode 100644 frontend/src/api/platforms.ts
create mode 100644 frontend/src/components/ProjectFamilyTab.vue
create mode 100644 frontend/src/stores/platforms.ts
create mode 100644 src/scribe/mcp/tools/platforms.py
create mode 100644 src/scribe/routes/platforms.py
create mode 100644 src/scribe/services/platforms.py
create mode 100644 tests/test_integration_platforms.py
create mode 100644 tests/test_platforms.py
create mode 100644 tests/test_routes_platforms.py
diff --git a/frontend/src/api/inception.ts b/frontend/src/api/inception.ts
index ece42631..f520cc21 100644
--- a/frontend/src/api/inception.ts
+++ b/frontend/src/api/inception.ts
@@ -1,9 +1,14 @@
/** Project inception (milestone 297): what a project was decided to inherit. */
import { apiGet, apiPost } from "@/api/client";
+import type { ProjectPlatform } from "@/api/platforms";
export interface InceptionChoices {
design_system_id: number | null;
seed_systems: boolean;
+ /** Platform slugs (milestone 463). null = not answered; a list is the
+ * whole answer — members left out of it are recorded as "not this".
+ * Absent on records decided before the question existed. */
+ platforms?: string[] | null;
}
export interface InceptionRecord {
@@ -17,16 +22,24 @@ export interface InceptionDefaults {
design_system_id: number | null;
design_systems: { id: number; title: string }[];
systems: number;
+ platforms: { slug: string; name: string }[];
+ /** What detection (or an earlier answer) already says about the project. */
+ project_platforms: ProjectPlatform[];
}
export interface InceptionDecision {
project_id: number;
inception: InceptionRecord;
- effects: { design_system_id: number | null; systems_seeded: string[] };
+ effects: {
+ design_system_id: number | null;
+ systems_seeded: string[];
+ /** The project's answers after the decision; null when left unstated. */
+ platforms: ProjectPlatform[] | null;
+ };
}
export const emptyChoices = (): InceptionChoices => ({
- design_system_id: null, seed_systems: false,
+ design_system_id: null, seed_systems: false, platforms: null,
});
export const fetchInceptionDefaults = (projectId: number) =>
diff --git a/frontend/src/api/platforms.ts b/frontend/src/api/platforms.ts
new file mode 100644
index 00000000..8e27cd58
--- /dev/null
+++ b/frontend/src/api/platforms.ts
@@ -0,0 +1,77 @@
+/**
+ * Platforms — what a project is built on or ships as (milestone 463).
+ *
+ * The catalog is GLOBAL, like the canonical areas, and admin-written. A
+ * project's answer per platform is `declared` (a person said so), `detected`
+ * (a marker file in a bound repo said so) or `rejected` (a person said no —
+ * kept, so the next refresh cannot detect it back). Only declared and
+ * detected make a project a member, and membership is what decides which
+ * family ideas reach it.
+ */
+import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client";
+
+export interface Platform {
+ id: number;
+ name: string;
+ /** The match key, and the name every door takes (stable across restores). */
+ slug: string;
+ description: string | null;
+ /** Repo-relative globs. A bare name matches a basename anywhere; one with
+ * a slash matches the whole path. Empty = declare-only. */
+ markers: string[];
+ order_index: number;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+export type PlatformState = "declared" | "detected" | "rejected";
+/** What a person may set. `null` withdraws the answer. */
+export type SettablePlatformState = "declared" | "rejected" | null;
+
+export interface ProjectPlatform {
+ id: number;
+ slug: string;
+ name: string;
+ state: PlatformState;
+}
+
+export const MEMBER_STATES: PlatformState[] = ["declared", "detected"];
+
+export async function listPlatforms(): Promise {
+ const data = await apiGet<{ platforms: Platform[] }>("/api/platforms");
+ return data.platforms;
+}
+
+/** Admin only. A name that reduces to an existing slug answers 409. */
+export async function createPlatform(data: {
+ name: string;
+ description?: string;
+ markers?: string[];
+}): Promise {
+ return apiPost("/api/platforms", data);
+}
+
+export async function updatePlatform(
+ id: number,
+ data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
+): Promise {
+ return apiPatch(`/api/platforms/${id}`, data);
+}
+
+export async function fetchProjectPlatforms(projectId: number): Promise {
+ const data = await apiGet<{ project_platforms: ProjectPlatform[] }>(
+ `/api/projects/${projectId}/platforms`,
+ );
+ return data.project_platforms;
+}
+
+/** Only the slugs named change; the update applies whole or not at all. */
+export async function setProjectPlatforms(
+ projectId: number,
+ platforms: Record,
+): Promise {
+ const data = await apiPut<{ project_platforms: ProjectPlatform[] }>(
+ `/api/projects/${projectId}/platforms`, { platforms },
+ );
+ return data.project_platforms;
+}
diff --git a/frontend/src/components/InceptionCard.vue b/frontend/src/components/InceptionCard.vue
index 11929960..0d7b8ffa 100644
--- a/frontend/src/components/InceptionCard.vue
+++ b/frontend/src/components/InceptionCard.vue
@@ -6,10 +6,16 @@
* second step and only emits the choices (the project does not exist yet);
* mode="decide" sits on ProjectView for an undecided project, loads that
* project's current defaults, and records the decision itself.
+ *
+ * Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes
+ * the list the whole answer; in decide mode the boxes start from what
+ * detection already found, so recording confirms it.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import { fetchDesignSystems } from "@/api/designSystems";
+import { MEMBER_STATES } from "@/api/platforms";
+import { usePlatformsStore } from "@/stores/platforms";
import {
decideInception, emptyChoices, fetchInceptionDefaults,
type InceptionChoices, type InceptionDecision, type InceptionDefaults,
@@ -29,6 +35,9 @@ const emit = defineEmits<{
const local = ref(props.choices ? { ...props.choices } : emptyChoices());
const designSystems = ref<{ id: number; title: string }[]>([]);
const systemsCount = ref(0);
+const platforms = ref<{ slug: string; name: string }[]>([]);
+const detected = ref>(new Set());
+const platformsStore = usePlatformsStore();
const loading = ref(true);
const saving = ref(false);
const error = ref("");
@@ -46,11 +55,25 @@ async function load() {
const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId);
designSystems.value = d.design_systems;
systemsCount.value = d.systems;
+ platforms.value = d.platforms;
+ const members = d.project_platforms
+ .filter((p) => MEMBER_STATES.includes(p.state))
+ .map((p) => p.slug);
+ detected.value = new Set(
+ d.project_platforms.filter((p) => p.state === "detected").map((p) => p.slug),
+ );
// Start from what stands today, so "record" without changes keeps it.
- local.value = { design_system_id: d.design_system_id, seed_systems: false };
+ local.value = {
+ design_system_id: d.design_system_id,
+ seed_systems: false,
+ platforms: members.length ? members : null,
+ };
} else {
- const ds = await fetchDesignSystems();
+ const [ds, catalog] = await Promise.all([
+ fetchDesignSystems(), platformsStore.fetchCatalog(),
+ ]);
designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title }));
+ platforms.value = catalog.map((p) => ({ slug: p.slug, name: p.name }));
}
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not load what this project could inherit");
@@ -61,6 +84,22 @@ async function load() {
const nothingToDecide = computed(() => !designSystems.value.length);
+function isChecked(slug: string): boolean {
+ return (local.value.platforms ?? []).includes(slug);
+}
+
+function togglePlatform(slug: string, on: boolean) {
+ const current = new Set(local.value.platforms ?? []);
+ if (on) current.add(slug);
+ else current.delete(slug);
+ local.value.platforms = [...current].sort();
+}
+
+/** Back to "not answered" — distinct from an empty list, which says "none". */
+function clearPlatforms() {
+ local.value.platforms = null;
+}
+
async function record() {
if (!props.projectId) return;
saving.value = true;
@@ -105,6 +144,31 @@ onMounted(load);
+
+
Platforms
+
+ What it is built on or ships as. Ideas the family has proven for a
+ platform reach every project that is one.
+ Not answered yet.
+
+ Anything left unticked is recorded as "not this project".
+
+
+
+
+
+
+
No design systems on this install yet — recording still settles the question.
+ What this project is built on or ships as. Ideas the family has proven
+ for a platform reach every project that is one, and each project
+ answers them — adopts, varies with a reason, or owes the work.
+
+
+
+
Loading…
+
+
+ The platforms couldn't load.
+
Nothing is known either way — this is a failure, not an empty answer.
+
+
+
+
+ This install has no platforms in its catalog yet. An administrator adds
+ them in Settings.
+
+
+
+
+
+ A member of {{ memberCount }} platform{{ memberCount === 1 ? "" : "s" }}.
+
+
+ Not a member of any platform yet — bind a repo and refresh coverage
+ to detect them, or answer below.
+
+
+
+
+
diff --git a/frontend/src/stores/platforms.ts b/frontend/src/stores/platforms.ts
new file mode 100644
index 00000000..531427f3
--- /dev/null
+++ b/frontend/src/stores/platforms.ts
@@ -0,0 +1,48 @@
+import { ref } from "vue";
+import { defineStore } from "pinia";
+import * as api from "@/api/platforms";
+import type { Platform } from "@/api/platforms";
+
+/**
+ * The global platform catalog (milestone 463). Shared by every project, so
+ * it is fetched once per session — as the canonical-area catalog is.
+ */
+export const usePlatformsStore = defineStore("platforms", () => {
+ const catalog = ref([]);
+ const loaded = ref(false);
+ const loading = ref(false);
+
+ async function fetchCatalog(force = false) {
+ if (loaded.value && !force) return catalog.value;
+ loading.value = true;
+ try {
+ catalog.value = await api.listPlatforms();
+ loaded.value = true;
+ } catch {
+ // The catalog is a vocabulary for a form; an empty one degrades the
+ // form rather than failing the screen it sits on.
+ catalog.value = [];
+ } finally {
+ loading.value = false;
+ }
+ return catalog.value;
+ }
+
+ async function createEntry(data: { name: string; description?: string; markers?: string[] }) {
+ const entry = await api.createPlatform(data);
+ catalog.value.push(entry);
+ return entry;
+ }
+
+ async function updateEntry(
+ id: number,
+ data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
+ ) {
+ const entry = await api.updatePlatform(id, data);
+ const idx = catalog.value.findIndex((p) => p.id === id);
+ if (idx >= 0) catalog.value[idx] = entry;
+ return entry;
+ }
+
+ return { catalog, loaded, loading, fetchCatalog, createEntry, updateEntry };
+});
diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue
index ae9bdafc..48e0ef90 100644
--- a/frontend/src/views/ProjectView.vue
+++ b/frontend/src/views/ProjectView.vue
@@ -12,6 +12,8 @@ import KindBadge from "@/components/KindBadge.vue";
import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue";
import type { TaskKind } from "@/types/note";
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
+import ProjectFamilyTab from "@/components/ProjectFamilyTab.vue";
+import { canWriteRecord } from "@/utils/permission";
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import SystemsSection from "@/components/SystemsSection.vue";
import InceptionCard from "@/components/InceptionCard.vue";
@@ -125,7 +127,7 @@ async function confirmStartPlanning() {
const saving = ref(false);
const error = ref(null);
-const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
+const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks");
const tasks = ref([]);
const notes = ref([]);
@@ -747,6 +749,10 @@ async function confirmDelete() {
Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }}
· design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }}
· Systems seeded
+
+
+ · platforms {{ project.inception.choices.platforms.join(", ") }}
+
@@ -945,6 +951,9 @@ async function confirmDelete() {
+
@@ -1190,6 +1199,13 @@ async function confirmDelete() {
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
+
+
+
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 94875ae2..e994238b 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { useCanonicalSystemsStore } from "@/stores/canonicalSystems";
+import { usePlatformsStore } from "@/stores/platforms";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -71,6 +72,75 @@ async function saveArea() {
savingArea.value = false;
}
}
+// ── Platforms (milestone 463) ───────────────────────────────────────────
+// The global list of what a project can be built on or ship as. Same split
+// as the areas: admin-only to write, readable by everyone. Markers are the
+// repo-relative globs that let a coverage refresh DETECT a platform; they
+// are edited one per line, since a glob may itself contain a comma.
+const platformsStore = usePlatformsStore();
+const newPlatformName = ref("");
+const newPlatformDescription = ref("");
+const newPlatformMarkers = ref("");
+const creatingPlatform = ref(false);
+const editingPlatformId = ref(null);
+const editPlatformName = ref("");
+const editPlatformDescription = ref("");
+const editPlatformMarkers = ref("");
+const savingPlatform = ref(false);
+
+function markerLines(text: string): string[] {
+ return text.split("\n").map((m) => m.trim()).filter(Boolean);
+}
+
+async function createPlatform() {
+ const name = newPlatformName.value.trim();
+ if (!name || creatingPlatform.value) return;
+ creatingPlatform.value = true;
+ try {
+ await platformsStore.createEntry({
+ name,
+ description: newPlatformDescription.value.trim() || undefined,
+ markers: markerLines(newPlatformMarkers.value),
+ });
+ newPlatformName.value = "";
+ newPlatformDescription.value = "";
+ newPlatformMarkers.value = "";
+ toastStore.show("Platform added");
+ } catch (e) {
+ // A 409 names the existing platform the new name reduces to.
+ toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error");
+ } finally {
+ creatingPlatform.value = false;
+ }
+}
+
+function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) {
+ editingPlatformId.value = id;
+ editPlatformName.value = name;
+ editPlatformDescription.value = description ?? "";
+ editPlatformMarkers.value = markers.join("\n");
+}
+
+async function savePlatform() {
+ const id = editingPlatformId.value;
+ const name = editPlatformName.value.trim();
+ if (id == null || !name || savingPlatform.value) return;
+ savingPlatform.value = true;
+ try {
+ await platformsStore.updateEntry(id, {
+ name,
+ description: editPlatformDescription.value.trim(),
+ markers: markerLines(editPlatformMarkers.value),
+ });
+ editingPlatformId.value = null;
+ toastStore.show("Platform updated");
+ } catch (e) {
+ toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error");
+ } finally {
+ savingPlatform.value = false;
+ }
+}
+
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
@@ -479,7 +549,7 @@ async function copyCommit() {
const restoreFileInput = ref(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
-const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]);
+const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -489,6 +559,7 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
else if (tab === "areas") canonStore.fetchCatalog(true);
+ else if (tab === "platforms") platformsStore.fetchCatalog(true);
else if (tab === "config" && !versionInfo.value) loadVersionPanel();
}
if (tab === "apikeys") { fetchApiKeys(); }
@@ -1611,7 +1682,7 @@ async function deleteUser(userId: number) {
Admin
+
+
+
+
Platforms
+
+ What a project can be built on or ship as. A project's platforms decide which family
+ ideas reach it. Markers are repo-relative file patterns, one per line: a bare name
+ (go.mod) matches that file anywhere in a bound repo, and a pattern with a
+ slash (.github/workflows/*) matches the whole path. A platform with no
+ markers is never detected — projects declare it themselves.
+
+
+
+
+
+
+
+
+
+
+ {{ entry.name }}
+ {{ entry.slug }}
+
+
{{ entry.description }}
+
+
+ Detected by
+ {{ m }}
+
+ Declare-only — never detected.
+
+
+
+
+
+
+
+ No platforms yet.
+
+
+
+
+
+
@@ -4386,6 +4550,8 @@ async function deleteUser(userId: number) {
.area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; }
.area-admin-create { margin-top: var(--fs-space-4); }
.area-admin-actions { display: flex; gap: 0.4rem; }
+.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; }
+.area-admin-desc .area-admin-slug { margin-right: 0.25rem; }
/* The retrieval tuning trail (#4102). Reads as a record, not a control panel:
the operator is reviewing what was done, and the reason is the part worth
diff --git a/src/scribe/app.py b/src/scribe/app.py
index 0ebfa7da..a0d4c031 100644
--- a/src/scribe/app.py
+++ b/src/scribe/app.py
@@ -32,6 +32,7 @@ from scribe.routes.trash import trash_bp
from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
from scribe.routes.canonical_systems import canonical_systems_bp
+from scribe.routes.platforms import platforms_bp
from scribe.routes.lessons import lessons_bp
from scribe.routes.snippets import snippets_bp
from scribe.routes.webhooks import webhooks_bp
@@ -101,6 +102,7 @@ def create_app() -> Quart:
app.register_blueprint(dashboard_bp)
app.register_blueprint(systems_bp)
app.register_blueprint(canonical_systems_bp)
+ app.register_blueprint(platforms_bp)
app.register_blueprint(snippets_bp)
app.register_blueprint(webhooks_bp)
diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py
index 7e4f628c..18520615 100644
--- a/src/scribe/mcp/server.py
+++ b/src/scribe/mcp/server.py
@@ -160,6 +160,9 @@ _READ_ONLY_TOOLS = frozenset({
# retrieval_telemetry's reason, and needed by a read key so that a line
# naming a moment can be understood by whoever was shown it.
"list_moments",
+ # The platform catalog and a project's answers (milestone 463). A pure
+ # read; set_project_platforms is the write.
+ "list_platforms",
# The pass over the corpus and its queue (milestone 458 step 7): which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
@@ -183,6 +186,7 @@ _WRITE_TOOLS = frozenset({
# projects, Systems, repos
"create_project", "update_project", "delete_project", "decide_project_inception",
"create_system", "update_system", "delete_system", "map_system_to_canonical",
+ "set_project_platforms",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py
index 78bb9671..2bc5704b 100644
--- a/src/scribe/mcp/tools/__init__.py
+++ b/src/scribe/mcp/tools/__init__.py
@@ -6,7 +6,7 @@ from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
- moments, retrieval_review, retrieval_tuning,
+ moments, platforms, retrieval_review, retrieval_tuning,
wide_net,
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
)
@@ -24,6 +24,7 @@ def register_all(mcp) -> None:
projects.register(mcp)
milestones.register(mcp)
systems.register(mcp)
+ platforms.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
diff --git a/src/scribe/mcp/tools/platforms.py b/src/scribe/mcp/tools/platforms.py
new file mode 100644
index 00000000..ddc23fae
--- /dev/null
+++ b/src/scribe/mcp/tools/platforms.py
@@ -0,0 +1,78 @@
+"""Platform MCP tools — what a project is built on or ships as (milestone 463).
+
+Thin wrappers over services/platforms.py. The catalog is global; a project's
+platforms decide which family ideas reach it.
+"""
+from __future__ import annotations
+
+from scribe.mcp._context import current_user_id
+from scribe.services import platforms as platforms_svc
+
+
+async def list_platforms(project_id: int = 0) -> dict:
+ """The GLOBAL platform catalog — runtimes, delivery channels and
+ toolchains a project can be built on or ship as (Android app, container
+ image, Go, …) — and, with a project_id, that project's answer for each.
+
+ A project's platforms decide which family ideas reach it: an idea is for
+ some platforms, and every project that is one of them answers it. Pass
+ slugs from here to set_project_platforms and decide_project_inception.
+
+ Each platform's `markers` are the file patterns that let the coverage
+ refresh DETECT it in a bound repo. A project's `state` per platform is
+ `declared` (a person said so), `detected` (a marker said so) or `rejected`
+ (a person said no — kept so detection cannot add it back). Only declared
+ and detected are membership.
+
+ Args:
+ project_id: also return this project's answers. 0 = catalog only.
+ """
+ catalog = await platforms_svc.list_platforms()
+ out: dict = {"platforms": [p.to_dict() for p in catalog]}
+ if project_id:
+ rows = await platforms_svc.project_platforms(current_user_id(), project_id)
+ if rows is None:
+ raise ValueError(f"project {project_id} not found")
+ out["project_platforms"] = rows
+ return out
+
+
+async def set_project_platforms(
+ project_id: int,
+ declared: list[str] | None = None,
+ rejected: list[str] | None = None,
+ withdrawn: list[str] | None = None,
+) -> dict:
+ """Say which platforms a project is — or is not. Only the platforms named
+ change; everything else is left exactly as it is.
+
+ Use it when the operator corrects what detection found, or when a project
+ starts or stops shipping something (it gains an Android client; it drops
+ its container image). At project creation, decide_project_inception's
+ `platforms` is the place instead — it records the answer with the rest of
+ what the project inherits.
+
+ Args:
+ project_id: the project.
+ declared: slugs the project IS (list_platforms).
+ rejected: slugs it is NOT — recorded as a "no", so the coverage
+ refresh never detects it back.
+ withdrawn: slugs whose answer to drop entirely, so detection may
+ decide again on the next refresh.
+ """
+ updates: dict[str, str | None] = {}
+ for slug in withdrawn or []:
+ updates[slug] = None
+ for slug in rejected or []:
+ updates[slug] = "rejected"
+ for slug in declared or []:
+ updates[slug] = "declared"
+ if not updates:
+ raise ValueError("name at least one platform to declare, reject or withdraw")
+ rows = await platforms_svc.set_project_platforms(current_user_id(), project_id, updates)
+ return {"project_id": project_id, "project_platforms": rows}
+
+
+def register(mcp) -> None:
+ for fn in (list_platforms, set_project_platforms):
+ mcp.tool(name=fn.__name__)(fn)
diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py
index 56491c21..087feecf 100644
--- a/src/scribe/mcp/tools/projects.py
+++ b/src/scribe/mcp/tools/projects.py
@@ -23,6 +23,7 @@ from scribe.services import design_systems as design_systems_svc
from scribe.services import inception as inception_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
+from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import rulebooks as rulebooks_svc
from scribe.services import systems as systems_svc
@@ -125,9 +126,15 @@ async def enter_project(project_id: int) -> dict:
create it with create_system rather than leaving the area unmodelled. Read
a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter.
+ `platforms` (milestone 463) is what the project is built on or ships as
+ — Android app, container image, Go, … — each with how it is known
+ (`declared` by a person, `detected` from a bound repo). They decide which
+ family ideas reach this project. An empty list on a project that plainly
+ ships something is worth correcting with set_project_platforms.
+
`inception` (milestone 297) appears ONLY when the project is yours and
nobody has decided what it inherits: it carries the current defaults
- (design system, Systems), what to ask the
+ (design system, Systems, platforms), what to ask the
operator — once — and the decide_project_inception call that answers it;
it repeats on every enter until a decision is recorded.
@@ -188,6 +195,9 @@ async def enter_project(project_id: int) -> dict:
# three days of the feature landing (#2546's audit). Untagged writes now
# also ask with the vocabulary listed; this copy lets the first write tag.
systems = await systems_svc.list_systems(uid, project_id)
+ platforms = platforms_svc.members(
+ await platforms_svc.project_platforms(uid, project_id) or []
+ )
# The arrival-moment half of the bootstrap ask (#2683): session start is
# when the agent has just read the project map and is not yet deep in a
@@ -258,6 +268,7 @@ async def enter_project(project_id: int) -> dict:
},
"pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None,
"systems": [{"id": s.id, "name": s.name} for s in systems],
+ "platforms": platforms,
"design_system": design_system,
"milestone_summary": milestone_summary,
**rulebooks_svc.rules_payload(
@@ -302,12 +313,15 @@ async def get_project(project_id: int) -> dict:
rules (project_rules), and applicable_rules: the
global rules tagged to an area this project works in. Every other global
rule applies too and arrives by retrieval when the work matches it.
+ `platforms` is every platform the project has an answer for, with its
+ state — rejected ones included, unlike enter_project's brief list.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
if project is None:
raise ValueError(f"project {project_id} not found")
data = project.to_dict()
+ data["platforms"] = await platforms_svc.project_platforms(uid, project_id) or []
rows = await milestones_svc.get_project_milestone_summary(uid, project_id)
data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows)
applicable = await rulebooks_svc.get_applicable_rules(
@@ -317,17 +331,21 @@ async def get_project(project_id: int) -> dict:
return data
-def _inception_choices(design_system_id, seed_systems) -> dict | None:
+def _inception_choices(design_system_id, seed_systems, platforms=None) -> dict | None:
"""The tool args → an inception choices object, or None when no inception
arg was given at all (a bare create stays undecided and enter_project
asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that
- system."""
- if not design_system_id and seed_systems is None:
+ system. platforms: None = not stated (memberships untouched), a list =
+ the whole answer."""
+ if not design_system_id and seed_systems is None and platforms is None:
return None
- return {
+ choices = {
"design_system_id": None if design_system_id in (0, -1) else design_system_id,
"seed_systems": bool(seed_systems),
}
+ if platforms is not None:
+ choices["platforms"] = list(platforms)
+ return choices
async def create_project(
@@ -338,11 +356,12 @@ async def create_project(
color: str = "",
design_system_id: int = 0,
seed_systems: bool | None = None,
+ platforms: list[str] | None = None,
) -> dict:
"""Create a new project in Scribe — and decide what it inherits.
A project's inheritance is a decision, not a default (milestone 297):
- before calling, ask the operator the two inception questions and pass
+ before calling, ask the operator the three inception questions and pass
the answers; a project created without either is UNDECIDED and
enter_project will ask until decide_project_inception records it.
Defaults if nobody decides: no design system, no Systems. Rules are not
@@ -359,6 +378,9 @@ async def create_project(
(list_design_systems); -1 = explicitly none; 0 = not stated.
seed_systems: true mints the standard starter Systems (CI & Release,
Auth & Access, …) so records can be tagged from day one.
+ platforms: slugs of what the project is built on or ships as
+ (list_platforms) — they decide which family ideas reach it. The
+ list is the whole answer; omit it to leave the question open.
"""
uid = current_user_id()
project = await projects_svc.create_project(
@@ -370,7 +392,7 @@ async def create_project(
color=color or None,
)
data = project.to_dict()
- choices = _inception_choices(design_system_id, seed_systems)
+ choices = _inception_choices(design_system_id, seed_systems, platforms)
if choices is not None:
decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp")
data["inception"] = decided["inception"]
@@ -388,6 +410,7 @@ async def decide_project_inception(
project_id: int,
design_system_id: int = 0,
seed_systems: bool | None = None,
+ platforms: list[str] | None = None,
) -> dict:
"""Record what a project inherits — answer enter_project's `inception` ask,
or re-decide later (milestone 297).
@@ -400,10 +423,10 @@ async def decide_project_inception(
Args: as create_project's inception args. Passing nothing records a
decision to take nothing (no design system, no seed) — a valid answer,
- stated.
+ stated — and leaves the project's platforms as they are.
"""
uid = current_user_id()
- choices = _inception_choices(design_system_id, seed_systems) or {}
+ choices = _inception_choices(design_system_id, seed_systems, platforms) or {}
decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp")
return {"project_id": project_id, **decided}
diff --git a/src/scribe/routes/platforms.py b/src/scribe/routes/platforms.py
new file mode 100644
index 00000000..0c040aad
--- /dev/null
+++ b/src/scribe/routes/platforms.py
@@ -0,0 +1,96 @@
+"""Platform routes — the GLOBAL platform catalog, and which platforms a
+project is (milestone 463 step 2).
+
+Two shapes, as with the canonical areas:
+
+- `/api/platforms` — the catalog. Readable by any signed-in user; writable
+ only by an admin, since a vocabulary anyone extends stops being shared.
+- `/api/projects//platforms` — a project's answers, authorised by the
+ PROJECT (read to see them, write to change them). The service enforces
+ both; these are thin wrappers.
+"""
+import logging
+
+from quart import Blueprint, jsonify, request
+
+from scribe.auth import admin_required, get_current_user_id, login_required
+from scribe.routes.utils import not_found
+from scribe.services import platforms as platforms_svc
+
+logger = logging.getLogger(__name__)
+
+platforms_bp = Blueprint("platforms", __name__, url_prefix="/api")
+
+_EDITABLE = ("name", "description", "order_index", "markers")
+
+
+@platforms_bp.route("/platforms", methods=["GET"])
+@login_required
+async def list_platforms_route():
+ entries = await platforms_svc.list_platforms()
+ return jsonify({"platforms": [e.to_dict() for e in entries]})
+
+
+@platforms_bp.route("/platforms", methods=["POST"])
+@admin_required
+async def create_platform_route():
+ uid = get_current_user_id()
+ data = await request.get_json() or {}
+ if not (data.get("name") or "").strip():
+ return jsonify({"error": "name is required"}), 400
+ try:
+ entry = await platforms_svc.create_platform(
+ uid, data["name"], description=data.get("description"),
+ markers=data.get("markers"),
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+ if entry is None:
+ return jsonify({"error": "Permission denied"}), 403
+ # Duplicate-gated on the slug: the entry that already is this platform
+ # comes back as a 409 rather than a second spelling of it.
+ if isinstance(entry, dict):
+ return jsonify(entry), 409
+ return jsonify(entry.to_dict()), 201
+
+
+@platforms_bp.route("/platforms/", methods=["PATCH"])
+@admin_required
+async def update_platform_route(platform_id: int):
+ uid = get_current_user_id()
+ data = await request.get_json() or {}
+ fields = {k: v for k, v in data.items() if k in _EDITABLE}
+ try:
+ entry = await platforms_svc.update_platform(uid, platform_id, **fields)
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+ if entry is None:
+ return not_found("Platform")
+ return jsonify(entry.to_dict())
+
+
+@platforms_bp.route("/projects//platforms", methods=["GET"])
+@login_required
+async def project_platforms_route(project_id: int):
+ """Every platform the project has an answer for — rejected included, so
+ a screen can show "no" as well as "yes"."""
+ rows = await platforms_svc.project_platforms(get_current_user_id(), project_id)
+ if rows is None:
+ return not_found("Project")
+ return jsonify({"project_platforms": rows})
+
+
+@platforms_bp.route("/projects//platforms", methods=["PUT"])
+@login_required
+async def set_project_platforms_route(project_id: int):
+ """Body: {"platforms": {: "declared" | "rejected" | null}}. Only
+ the slugs named change; null withdraws the answer. Applies whole or not
+ at all."""
+ data = await request.get_json() or {}
+ try:
+ rows = await platforms_svc.set_project_platforms(
+ get_current_user_id(), project_id, data.get("platforms"),
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 400
+ return jsonify({"project_platforms": rows})
diff --git a/src/scribe/services/access.py b/src/scribe/services/access.py
index 2a433741..31f8cc87 100644
--- a/src/scribe/services/access.py
+++ b/src/scribe/services/access.py
@@ -103,6 +103,15 @@ async def can_admin_project(user_id: int, project_id: int) -> bool:
return perm in ("admin", "owner")
+async def is_instance_admin(user_id: int) -> bool:
+ """Whether the user administers the INSTANCE (users.role == "admin") —
+ the gate on the global catalogs (canonical areas, platforms), which belong
+ to no user and no project, so no share can grant a write to them."""
+ async with async_session() as session:
+ role = await session.scalar(select(User.role).where(User.id == user_id))
+ return role == "admin"
+
+
# ---------------------------------------------------------------------------
# Note / task permissions
# ---------------------------------------------------------------------------
diff --git a/src/scribe/services/canonical_systems.py b/src/scribe/services/canonical_systems.py
index d1c56dd7..d6c43136 100644
--- a/src/scribe/services/canonical_systems.py
+++ b/src/scribe/services/canonical_systems.py
@@ -30,7 +30,6 @@ from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.system import System
-from scribe.models.user import User
from scribe.services import access
logger = logging.getLogger(__name__)
@@ -60,9 +59,7 @@ def _tokens(slug: str) -> frozenset[str]:
async def _is_admin(user_id: int) -> bool:
- async with async_session() as session:
- role = await session.scalar(select(User.role).where(User.id == user_id))
- return role == "admin"
+ return await access.is_instance_admin(user_id)
async def list_canonical_systems() -> list[CanonicalSystem]:
diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py
index b8f6bf99..7f8e3b28 100644
--- a/src/scribe/services/coverage.py
+++ b/src/scribe/services/coverage.py
@@ -660,6 +660,11 @@ class ArchiveScan(NamedTuple):
definitions: list[ArchiveShape]
references: dict[str, dict[str, int]] # path → class token → count
+ # EVERY file's repo-relative path, scannable or not (milestone 463). The
+ # platform markers are files this scan otherwise skips — go.mod,
+ # AndroidManifest.xml, a Dockerfile — so detection reads the names here
+ # rather than re-walking the tarball.
+ paths: tuple[str, ...] = ()
def definitions_from_archive(blob: bytes) -> list[ArchiveShape]:
@@ -678,11 +683,14 @@ def scan_archive(blob: bytes) -> ArchiveScan:
"""
shapes: list[ArchiveShape] = []
references: dict[str, dict[str, int]] = {}
+ paths: list[str] = []
with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar:
for member in tar:
if not member.isfile() or "/" not in member.name:
continue
path = member.name.split("/", 1)[1]
+ if path:
+ paths.append(path)
if not path or not scannable(path) or member.size > _MAX_FILE_BYTES:
continue
handle = tar.extractfile(member)
@@ -708,7 +716,7 @@ def scan_archive(blob: bytes) -> ArchiveScan:
)
if refs:
references[path] = refs
- return ArchiveScan(shapes, references)
+ return ArchiveScan(shapes, references, tuple(paths))
# --- matching shapes against recorded locations ------------------------------
@@ -806,6 +814,8 @@ async def compute_coverage(
return None
served: list[tuple[str, str]] = []
+ # Every file path across the project's repos, for platform detection.
+ tree_paths: list[str] = []
recorded = await _recorded_locations(user_id, project_id)
# The proposer's canon catalog, read once per refresh and shared across
# the project's repos (#2792).
@@ -822,6 +832,7 @@ async def compute_coverage(
ref = binding.ref or await forge.default_branch(api_repo)
scan = scan_archive(await forge.archive(api_repo, ref))
definitions = scan.definitions
+ tree_paths.extend(scan.paths)
# The head commit is provenance sugar on the ledger rows; failing to
# learn it must not fail the sync — the ref names the point well
# enough and the row timestamps carry the when.
@@ -857,6 +868,16 @@ async def compute_coverage(
if not served:
return None
+ # Platform detection (milestone 463) rides the same walk: the paths are in
+ # hand once. It only ever ADDS membership the project has no answer for,
+ # and it must not be able to fail the refresh it rides on.
+ try:
+ from scribe.services import platforms as platforms_svc
+
+ await platforms_svc.detect_for_project(project_id, tree_paths)
+ except Exception:
+ logger.warning("platform detection failed for project %s", project_id, exc_info=True)
+
await shape_ledger.mark_canonicals(project_id, recorded)
try:
await shape_ledger.apply_derive_groups(project_id)
diff --git a/src/scribe/services/inception.py b/src/scribe/services/inception.py
index 52f838e2..4e9d48bd 100644
--- a/src/scribe/services/inception.py
+++ b/src/scribe/services/inception.py
@@ -8,7 +8,8 @@ A project's inheritance is a decision, not a default. The record lives on
"via": "mcp" | "ui" | "legacy",
"choices": {
"design_system_id": | null,
- "seed_systems": bool
+ "seed_systems": bool,
+ "platforms": [, ...] | null
}
}
@@ -29,6 +30,15 @@ applies the effects (each idempotent), and writes the record LAST, so a
half-applied decision is re-runnable rather than recorded as done.
``current_defaults`` is what the enter_project ask shows: what binds today
if nobody decides.
+
+``platforms`` (milestone 463) is which platforms the project IS — the answer
+that decides which family ideas reach it. A list of catalog SLUGS, not ids:
+the record is JSON, and a slug survives a backup restore onto an install whose
+ids differ, where an id inside JSON would come back naming another platform.
+NULL means the question was not answered here, and the project's memberships
+are left exactly as they are; a list is the full answer — every platform in it
+is declared, and any platform detection had added that is NOT in it is
+recorded as a "no", so the next refresh cannot put it back.
"""
from __future__ import annotations
@@ -38,7 +48,7 @@ from scribe.models import async_session
from scribe.models.project import Project
INCEPTION_VIAS = ("mcp", "ui", "legacy")
-CHOICE_KEYS = ("design_system_id", "seed_systems")
+CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms")
def validate_inception(choices) -> str | None:
@@ -46,8 +56,9 @@ def validate_inception(choices) -> str | None:
None. Pure and checked BEFORE any effect is applied: a decision either
applies whole or errors whole (the StrictArgs lesson, #2709).
- Accepts two keys, each optional: ``design_system_id`` an int or None,
- ``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice
+ Accepts three keys, each optional: ``design_system_id`` an int or None,
+ ``seed_systems`` a bool, ``platforms`` a list of slugs or None. Unknown
+ keys are an error — a typo, or a choice
the product no longer offers, must not become a silently ignored one."""
if not isinstance(choices, dict):
return "choices must be an object"
@@ -60,16 +71,26 @@ def validate_inception(choices) -> str | None:
seed = choices.get("seed_systems", False)
if not isinstance(seed, bool):
return "seed_systems must be true or false"
+ platforms = choices.get("platforms")
+ if platforms is not None and (
+ not isinstance(platforms, list)
+ or not all(isinstance(p, str) and p.strip() for p in platforms)
+ ):
+ return "platforms must be a list of platform slugs (list_platforms), or null"
return None
def normalize_choices(choices: dict | None) -> dict:
- """Both keys, always present, in canonical form — what gets stored
+ """Every key, always present, in canonical form — what gets stored
and what the UI/agent reads back. Call after validate_inception."""
choices = choices or {}
+ platforms = choices.get("platforms")
return {
"design_system_id": choices.get("design_system_id"),
"seed_systems": bool(choices.get("seed_systems", False)),
+ "platforms": (
+ sorted({p.strip() for p in platforms}) if platforms is not None else None
+ ),
}
@@ -81,11 +102,15 @@ def is_decided(project) -> bool:
async def current_defaults(user_id: int, project_id: int) -> dict:
"""What the project inherits if nobody decides — the ask's payload.
- {design_system_id, design_systems: [{id,title}], systems: }.
+ {design_system_id, design_systems: [{id,title}], systems: ,
+ platforms: [{slug,name}], project_platforms: [{slug,name,state}]}.
Instance-agnostic: an install with no design systems shows an empty list,
- and the ask says so rather than inventing a default.
+ and the ask says so rather than inventing a default. `project_platforms`
+ is what detection has already found (and anything already answered), so
+ the form can start from it.
"""
from scribe.services import design_systems as design_systems_svc
+ from scribe.services import platforms as platforms_svc
from scribe.services import projects as projects_svc
from scribe.services import systems as systems_svc
@@ -94,10 +119,13 @@ async def current_defaults(user_id: int, project_id: int) -> dict:
raise ValueError(f"project {project_id} not found")
designs = await design_systems_svc.list_design_systems(user_id)
systems = await systems_svc.list_systems(user_id, project_id, include_archived=True)
+ catalog = await platforms_svc.list_platforms()
return {
"design_system_id": project.design_system_id,
"design_systems": [{"id": d.id, "title": d.title} for d in designs],
"systems": len(systems),
+ "platforms": [{"slug": p.slug, "name": p.name} for p in catalog],
+ "project_platforms": await platforms_svc.project_platforms(user_id, project_id) or [],
}
@@ -106,9 +134,15 @@ async def _check_targets(user_id: int, choices: dict) -> None:
effect lands — a decision applies whole or errors whole."""
from scribe.services import access
+ from scribe.services import platforms as platforms_svc
+
ds = choices["design_system_id"]
if ds is not None and not await access.can_read_design_system(user_id, ds):
raise ValueError(f"design system {ds} not found (or not readable)")
+ if choices["platforms"]:
+ _, unknown = await platforms_svc.resolve_slugs(choices["platforms"])
+ if unknown:
+ raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
async def decide(
@@ -127,7 +161,8 @@ async def decide(
replaces the design system and re-seeds nothing a project already has.
Returns {"inception": , "effects": {design_system_id,
- systems_seeded}}.
+ systems_seeded, platforms}} — `platforms` is the project's answers after
+ the decision, or None when the choice was left unstated.
"""
from scribe.services import design_systems as design_systems_svc
from scribe.services import projects as projects_svc
@@ -152,6 +187,9 @@ async def decide(
await systems_svc.seed_standard_systems(user_id, project_id)
if choices["seed_systems"] else []
)
+ platforms = None
+ if choices["platforms"] is not None:
+ platforms = await _apply_platforms(user_id, project_id, choices["platforms"])
record = {
"decided_at": datetime.now(timezone.utc).isoformat(),
@@ -169,10 +207,41 @@ async def decide(
"effects": {
"design_system_id": choices["design_system_id"],
"systems_seeded": [sy.name for sy in seeded],
+ "platforms": platforms,
},
}
+async def _apply_platforms(user_id: int, project_id: int, slugs: list[str]) -> list[dict]:
+ """The platforms answer as membership. The list is the WHOLE answer:
+ every slug in it is declared, and every platform the project was a member
+ of (declared or detected) that is left out becomes rejected — so the next
+ refresh cannot detect back what the person just said the project isn't.
+ Platforms already rejected stay rejected; platforms never answered stay
+ unanswered."""
+ from scribe.services import platforms as platforms_svc
+
+ named = set(slugs)
+ current = await platforms_svc.project_platforms(user_id, project_id) or []
+ updates: dict[str, str | None] = {slug: "declared" for slug in named}
+ for row in current:
+ if row["slug"] not in named and row["state"] in platforms_svc.MEMBER_STATES:
+ updates[row["slug"]] = "rejected"
+ return await platforms_svc.set_project_platforms(user_id, project_id, updates)
+
+
+def _platform_line(defaults: dict) -> str:
+ """What the ask says about platforms: what detection already found, and
+ the catalog to choose from."""
+ found = [
+ p["slug"] for p in defaults.get("project_platforms", [])
+ if p["state"] in ("declared", "detected")
+ ]
+ catalog = ", ".join(p["slug"] for p in defaults.get("platforms", [])) or "none"
+ lead = f"detected so far: {', '.join(found)}; " if found else ""
+ return f"{lead}catalog: {catalog}"
+
+
async def inception_ask(user_id: int, project_id: int) -> dict:
"""The enter_project ask for an undecided project (milestone 297) — the
sibling of the systems-bootstrap ask (#2683): the project's OWN current
@@ -190,13 +259,16 @@ async def inception_ask(user_id: int, project_id: int) -> dict:
"inherits. Design system — "
f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} "
f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, "
- "once: which design system (or none), and whether to seed "
- "the standard starter Systems — then record the answers. This ask repeats on "
- "every enter_project until a decision is recorded."
+ "once: which design system (or none), whether to seed "
+ "the standard starter Systems, and which platforms the project is "
+ f"built on or ships as ({_platform_line(defaults)}) — then record the "
+ "answers. This ask repeats on every enter_project until a decision "
+ "is recorded."
),
"call": (
f"decide_project_inception(project_id={project_id}, "
- "design_system_id=, seed_systems=)"
+ "design_system_id=, seed_systems=, "
+ "platforms=[, ...])"
),
}
diff --git a/src/scribe/services/platforms.py b/src/scribe/services/platforms.py
new file mode 100644
index 00000000..675c72a5
--- /dev/null
+++ b/src/scribe/services/platforms.py
@@ -0,0 +1,335 @@
+"""Platforms — what a project is built on or ships as, and which projects are
+which (milestone 463 step 2).
+
+Membership is what makes "is this idea in family for that project?" a lookup
+rather than a judgment: a family idea is for some platforms, and it reaches
+every project that is a member of one of them.
+
+Three ways a project becomes, or refuses to become, a member:
+
+- **declared** — a person said so: at inception, or in the project's settings.
+- **detected** — a marker file in a bound repo said so, found by the coverage
+ refresh that already walks the repo archive.
+- **rejected** — a person said NO. Kept as a row, so the next refresh does not
+ detect it straight back.
+
+The one invariant everything here protects: **detection only ever ADDS, and
+only where nobody has answered.** It never overwrites a declared or rejected
+row, and it never removes anything — not even a detected row whose marker has
+since disappeared, because membership is what adoption rows hang off and a
+platform flickering in and out with a repo's file tree would churn a ledger
+of decisions nobody re-made.
+
+The catalog itself is global, like the canonical area catalog, and for the
+same reason writes to it are admin-only: a shared vocabulary anyone can extend
+stops being shared. Reads are open to any signed-in user.
+"""
+from __future__ import annotations
+
+import fnmatch
+import logging
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+
+from scribe.models import async_session
+from scribe.models.family import Platform, ProjectPlatform
+from scribe.services import access
+from scribe.services.canonical_systems import canonical_slug
+
+logger = logging.getLogger(__name__)
+
+# The states that make a project a member. `rejected` is an answer, not
+# membership.
+MEMBER_STATES = ("declared", "detected")
+# What a person may set from a door. `detected` is the refresh's to write.
+SETTABLE_STATES = ("declared", "rejected")
+
+
+# --- the catalog -------------------------------------------------------------
+
+async def list_platforms() -> list[Platform]:
+ """The whole live catalog, in display order. Global — no owner filter."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(Platform)
+ .where(Platform.deleted_at.is_(None))
+ .order_by(Platform.order_index.asc(), Platform.name.asc())
+ )
+ return list(result.scalars().all())
+
+
+def _clean_markers(markers) -> list[str]:
+ """Markers as stored: stripped, non-empty strings, de-duplicated in order.
+ Anything else is refused by the caller before it gets here."""
+ seen: list[str] = []
+ for m in markers or []:
+ m = str(m).strip()
+ if m and m not in seen:
+ seen.append(m)
+ return seen
+
+
+def validate_markers(markers) -> str | None:
+ """The error a markers value would earn, or None. Pure."""
+ if markers is None:
+ return None
+ if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers):
+ return "markers must be a list of glob patterns"
+ if any(m.strip().startswith("/") for m in markers):
+ return "markers are repo-relative — no leading slash"
+ return None
+
+
+async def create_platform(
+ user_id: int, name: str, *, description: str | None = None,
+ markers: list[str] | None = None,
+) -> Platform | dict | None:
+ """Add a platform to the global catalog. Admin only.
+
+ Duplicate-gated on the slug, so "Android App" cannot be added beside
+ "Android app": the existing entry comes back instead of a second spelling
+ of it. None means not permitted, or no usable name.
+ """
+ if not await access.is_instance_admin(user_id):
+ return None
+ slug = canonical_slug(name)
+ if not slug:
+ return None
+ error = validate_markers(markers)
+ if error:
+ raise ValueError(error)
+ async with async_session() as session:
+ existing = await session.scalar(
+ select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None))
+ )
+ if existing is not None:
+ return {
+ "duplicate": True,
+ "existing_id": existing.id,
+ "message": (
+ f"'{existing.name}' (#{existing.id}) is already this platform — "
+ f"both names reduce to '{slug}'."
+ ),
+ }
+ highest = await session.scalar(
+ select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1)
+ )
+ entry = Platform(
+ name=" ".join(name.split()),
+ slug=slug,
+ description=description,
+ markers=_clean_markers(markers),
+ order_index=(highest or 0) + 1,
+ )
+ session.add(entry)
+ await session.commit()
+ await session.refresh(entry)
+ return entry
+
+
+async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None:
+ """Rename, re-describe, re-order or re-mark a catalog entry. Admin only.
+
+ A rename recomputes the slug — the display name and the match key must not
+ disagree. Changing the slug of a platform projects already belong to is
+ safe: membership is by id; only a backup carries the slug.
+ """
+ if not await access.is_instance_admin(user_id):
+ return None
+ if "markers" in fields:
+ error = validate_markers(fields["markers"])
+ if error:
+ raise ValueError(error)
+ async with async_session() as session:
+ entry = await session.get(Platform, platform_id)
+ if entry is None or entry.deleted_at is not None:
+ return None
+ if fields.get("name"):
+ entry.name = " ".join(str(fields["name"]).split())
+ entry.slug = canonical_slug(entry.name)
+ if fields.get("description") is not None:
+ entry.description = fields["description"] or None
+ if fields.get("order_index") is not None:
+ entry.order_index = int(fields["order_index"])
+ if fields.get("markers") is not None:
+ entry.markers = _clean_markers(fields["markers"])
+ entry.updated_at = datetime.now(timezone.utc)
+ await session.commit()
+ await session.refresh(entry)
+ return entry
+
+
+async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]:
+ """{slug: id} for the slugs the live catalog knows, and the ones it does
+ not. Doors take slugs — readable, stable across installs, and the form an
+ inception record and a backup both carry."""
+ wanted = [s for s in dict.fromkeys(slugs or [])]
+ if not wanted:
+ return {}, []
+ async with async_session() as session:
+ rows = (await session.execute(
+ select(Platform.slug, Platform.id).where(
+ Platform.slug.in_(wanted), Platform.deleted_at.is_(None),
+ )
+ )).all()
+ known = {slug: pid for slug, pid in rows}
+ return known, [s for s in wanted if s not in known]
+
+
+# --- detection (pure) --------------------------------------------------------
+
+def marker_matches(marker: str, path: str) -> bool:
+ """Whether one marker matches one repo-relative path.
+
+ A marker with no slash matches a file's BASENAME anywhere in the tree
+ (`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash
+ matches the whole repo-relative path (`.github/workflows/*`), so a
+ directory-shaped marker cannot be satisfied by a same-named file
+ somewhere else.
+ """
+ marker = marker.strip()
+ if not marker:
+ return False
+ if "/" in marker:
+ return fnmatch.fnmatchcase(path, marker)
+ return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker)
+
+
+def detect(catalog: list, paths: list[str]) -> list[int]:
+ """The ids of every catalog platform at least one of whose markers matches
+ at least one path. Pure, so it is testable against a fixture tree with no
+ forge and no database. A platform with no markers is never detected —
+ that is what declare-only means."""
+ hits: list[int] = []
+ for platform in catalog:
+ markers = list(getattr(platform, "markers", None) or [])
+ if markers and any(marker_matches(m, p) for m in markers for p in paths):
+ hits.append(platform.id)
+ return hits
+
+
+# --- membership ----------------------------------------------------------------
+
+async def project_platforms(user_id: int, project_id: int) -> list[dict] | None:
+ """Every platform the project has an answer for, joined to the catalog.
+ None when the caller cannot read the project.
+
+ Rejected rows are included — a settings screen has to show "no" as well
+ as "yes", or it cannot let anyone change their mind.
+ """
+ if not await access.can_read_project(user_id, project_id):
+ return None
+ async with async_session() as session:
+ rows = (await session.execute(
+ select(ProjectPlatform, Platform)
+ .join(Platform, Platform.id == ProjectPlatform.platform_id)
+ .where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None))
+ .order_by(Platform.order_index.asc(), Platform.name.asc())
+ )).all()
+ return [
+ {"id": p.id, "slug": p.slug, "name": p.name, "state": m.state}
+ for m, p in rows
+ ]
+
+
+def members(platforms: list[dict]) -> list[dict]:
+ """The rows of `project_platforms` that are membership — the brief form
+ enter_project and get_project carry."""
+ return [
+ {"slug": p["slug"], "name": p["name"], "state": p["state"]}
+ for p in platforms if p["state"] in MEMBER_STATES
+ ]
+
+
+def validate_updates(updates) -> str | None:
+ """The error a set of membership answers would earn, or None. Pure.
+
+ `updates` is {slug: "declared" | "rejected" | None}. None withdraws the
+ answer — the row goes, and detection may add the platform again later.
+ `detected` is not settable: it is what the refresh writes, and a person
+ claiming it would erase the difference between the two."""
+ if not isinstance(updates, dict):
+ return "platforms must be an object of {slug: state}"
+ for slug, state in updates.items():
+ if not isinstance(slug, str) or not slug:
+ return "each platform is named by its slug"
+ if state is not None and state not in SETTABLE_STATES:
+ return (
+ f"'{state}' is not a state a person sets — use one of "
+ f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer"
+ )
+ return None
+
+
+async def set_project_platforms(
+ user_id: int, project_id: int, updates: dict[str, str | None],
+) -> list[dict]:
+ """Apply a person's answers about a project's platforms. Write-gated on
+ the project (rule 78). Platforms not named are left exactly as they are.
+
+ Raises ValueError, naming the problem, on a malformed update, an unknown
+ slug, or no write access — before anything is written, so the update
+ applies whole or not at all.
+ """
+ error = validate_updates(updates)
+ if error:
+ raise ValueError(error)
+ if not await access.can_write_project(user_id, project_id):
+ raise ValueError(f"project {project_id} not found or no write access")
+ known, unknown = await resolve_slugs(list(updates))
+ if unknown:
+ raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)")
+ async with async_session() as session:
+ existing = {
+ row.platform_id: row for row in (await session.execute(
+ select(ProjectPlatform).where(ProjectPlatform.project_id == project_id)
+ )).scalars().all()
+ }
+ for slug, state in updates.items():
+ pid = known[slug]
+ row = existing.get(pid)
+ if state is None:
+ if row is not None:
+ await session.delete(row)
+ elif row is None:
+ session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state))
+ else:
+ row.state = state
+ await session.commit()
+ return await project_platforms(user_id, project_id) or []
+
+
+async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]:
+ """Add `detected` membership for each platform the project has NO answer
+ for. Returns the ids actually added.
+
+ The refresh's writer, so it takes no user: it runs on the owner's behalf
+ inside a sync the owner's keyring authorised. It never updates or deletes
+ a row — see the module docstring for why that is the whole contract.
+ """
+ if not platform_ids:
+ return []
+ async with async_session() as session:
+ answered = set((await session.execute(
+ select(ProjectPlatform.platform_id).where(
+ ProjectPlatform.project_id == project_id,
+ )
+ )).scalars().all())
+ added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered]
+ for pid in added:
+ session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected"))
+ await session.commit()
+ return added
+
+
+async def detect_for_project(project_id: int, paths: list[str]) -> list[int]:
+ """Run detection over a refresh's paths and record what is new. The
+ coverage refresh's single call — it must not be able to fail that
+ refresh, so the caller wraps it."""
+ hits = detect(await list_platforms(), paths)
+ added = await record_detected(project_id, hits)
+ if added:
+ logger.info("project %s: detected platform(s) %s", project_id, added)
+ return added
+
diff --git a/tests/helpers.py b/tests/helpers.py
index 54473ab9..21ace608 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -512,3 +512,18 @@ def skill_text(name: str) -> str:
folder = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills" / name
refs = sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md")
return "\n\n".join(p.read_text() for p in [folder / "SKILL.md", *refs])
+
+
+def forge_tarball(files: dict[str, bytes], top: str = "widget") -> bytes:
+ """A gzipped tar shaped like a forge archive: every file under one
+ top-level directory (repo-ref/), which the scan strips."""
+ import io
+ import tarfile
+
+ buf = io.BytesIO()
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
+ for path, data in files.items():
+ info = tarfile.TarInfo(f"{top}/{path}")
+ info.size = len(data)
+ tar.addfile(info, io.BytesIO(data))
+ return buf.getvalue()
diff --git a/tests/test_inception.py b/tests/test_inception.py
index 8e316c5e..c85dc43a 100644
--- a/tests/test_inception.py
+++ b/tests/test_inception.py
@@ -27,7 +27,7 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it():
def test_validate_inception_pins_the_choice_vocabulary():
- assert CHOICE_KEYS == ("design_system_id", "seed_systems")
+ assert CHOICE_KEYS == ("design_system_id", "seed_systems", "platforms")
assert validate_inception({}) is None
assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None
assert validate_inception({"design_system_id": None}) is None
@@ -46,10 +46,28 @@ def test_validate_inception_pins_the_choice_vocabulary():
assert "true or false" in validate_inception({"seed_systems": "yes"})
+def test_validate_inception_takes_platforms_as_slugs_or_unstated():
+ """Slugs, not ids: an inception record outlives a restore, and a slug is
+ what both sides of a restore agree on. None is "not answered", which is
+ different from [] — "none of them"."""
+ assert validate_inception({"platforms": ["android-app", "go"]}) is None
+ assert validate_inception({"platforms": []}) is None
+ assert validate_inception({"platforms": None}) is None
+ assert "platform slugs" in validate_inception({"platforms": "android-app"})
+ assert "platform slugs" in validate_inception({"platforms": [3]})
+ assert "platform slugs" in validate_inception({"platforms": [" "]})
+
+
def test_normalize_choices_is_canonical_and_complete():
out = normalize_choices({"design_system_id": 4})
- assert out == {"design_system_id": 4, "seed_systems": False}
- assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False}
+ assert out == {"design_system_id": 4, "seed_systems": False, "platforms": None}
+ assert normalize_choices(None) == {
+ "design_system_id": None, "seed_systems": False, "platforms": None,
+ }
+ # Sorted and de-duplicated, so two equal answers record identically.
+ assert normalize_choices({"platforms": ["go", " android-app", "go"]})["platforms"] == [
+ "android-app", "go",
+ ]
def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant():
diff --git a/tests/test_integration_inception.py b/tests/test_integration_inception.py
index 8a92078e..abfd372a 100644
--- a/tests/test_integration_inception.py
+++ b/tests/test_integration_inception.py
@@ -37,14 +37,16 @@ async def seeded():
async def test_decide_applies_every_effect_and_records_last(seeded):
owner, pid = seeded["owner"], seeded["pid"]
defaults = await inception_svc.current_defaults(owner, pid)
- assert set(defaults) == {"design_system_id", "design_systems", "systems"}
+ assert set(defaults) == {
+ "design_system_id", "design_systems", "systems", "platforms", "project_platforms",
+ }
assert defaults["systems"] == 0 and defaults["design_system_id"] is None
out = await inception_svc.decide(owner, pid, via="mcp", choices={
"design_system_id": None,
"seed_systems": True,
})
- assert set(out["effects"]) == {"design_system_id", "systems_seeded"}
+ assert set(out["effects"]) == {"design_system_id", "systems_seeded", "platforms"}
catalog = await canonical_svc.list_canonical_systems()
assert len(out["effects"]["systems_seeded"]) == len(catalog)
# Seeded Systems come out mapped, not needing a later reconciliation.
@@ -56,7 +58,9 @@ async def test_decide_applies_every_effect_and_records_last(seeded):
project = await s.get(Project, pid)
assert inception_svc.is_decided(project)
assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner
- assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True}
+ assert project.inception["choices"] == {
+ "design_system_id": None, "seed_systems": True, "platforms": None,
+ }
# Re-deciding with seed again mints nothing twice.
again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True})
assert again["effects"]["systems_seeded"] == []
diff --git a/tests/test_integration_platforms.py b/tests/test_integration_platforms.py
new file mode 100644
index 00000000..2b152ef2
--- /dev/null
+++ b/tests/test_integration_platforms.py
@@ -0,0 +1,134 @@
+"""Real-Postgres checks for platform membership (milestone 463 step 2).
+
+The one invariant: detection only ever ADDS, and only where nobody has
+answered. A refresh that overwrote a "no" would put a project back into a
+family it was taken out of; one that overwrote a "yes" would erase who said
+it. Each is shown here against the real writer, beside the person-facing
+writes it must defer to.
+"""
+import pytest
+import pytest_asyncio
+from sqlalchemy import select
+
+from scribe.models import async_session
+from scribe.models.family import Platform
+from scribe.models.project import Project
+from scribe.services import inception as inception_svc
+from scribe.services import platforms as platforms_svc
+from tests.helpers import ensure_user
+
+pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
+
+OWNER = "platforms_owner"
+
+
+async def _id(slug: str) -> int:
+ async with async_session() as s:
+ return await s.scalar(
+ select(Platform.id).where(Platform.slug == slug, Platform.deleted_at.is_(None))
+ )
+
+
+@pytest_asyncio.fixture
+async def seeded():
+ """An owner, an outsider and a fresh project. Each test gets its own
+ project, so answers never leak between tests through a shared row."""
+ async with async_session() as s:
+ owner = await ensure_user(s, OWNER)
+ outsider = await ensure_user(s, "platforms_outsider")
+ project = Project(user_id=owner.id, title="an app with a server")
+ s.add(project)
+ await s.flush()
+ ids = {"owner": owner.id, "outsider": outsider.id, "pid": project.id}
+ await s.commit()
+ return ids
+
+
+def _states(rows: list[dict]) -> dict[str, str]:
+ return {r["slug"]: r["state"] for r in rows}
+
+
+async def test_detection_adds_only_where_nobody_has_answered(seeded):
+ owner, pid = seeded["owner"], seeded["pid"]
+ await platforms_svc.set_project_platforms(owner, pid, {
+ "go": "declared", "rust": "rejected",
+ })
+ added = await platforms_svc.detect_for_project(pid, [
+ "server/go.mod", "core/Cargo.toml", "app/src/main/AndroidManifest.xml",
+ ])
+ # Only android was unanswered; go and rust keep the person's answers.
+ assert added == [await _id("android-app")]
+ states = _states(await platforms_svc.project_platforms(owner, pid))
+ assert states == {"go": "declared", "rust": "rejected", "android-app": "detected"}
+
+
+async def test_detection_never_removes_a_platform_whose_marker_is_gone(seeded):
+ owner, pid = seeded["owner"], seeded["pid"]
+ await platforms_svc.detect_for_project(pid, ["server/go.mod"])
+ await platforms_svc.detect_for_project(pid, ["README.md"])
+ assert _states(await platforms_svc.project_platforms(owner, pid)) == {"go": "detected"}
+
+
+async def test_a_withdrawn_answer_lets_detection_decide_again(seeded):
+ owner, pid = seeded["owner"], seeded["pid"]
+ await platforms_svc.set_project_platforms(owner, pid, {"go": "rejected"})
+ assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == []
+ await platforms_svc.set_project_platforms(owner, pid, {"go": None})
+ assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == [await _id("go")]
+
+
+async def test_a_bad_update_writes_nothing(seeded):
+ owner, pid = seeded["owner"], seeded["pid"]
+ with pytest.raises(ValueError, match="unknown platform"):
+ await platforms_svc.set_project_platforms(owner, pid, {
+ "go": "declared", "not-a-platform": "declared",
+ })
+ assert await platforms_svc.project_platforms(owner, pid) == []
+ with pytest.raises(ValueError, match="no write access"):
+ await platforms_svc.set_project_platforms(seeded["outsider"], pid, {"go": "declared"})
+ assert await platforms_svc.project_platforms(seeded["outsider"], pid) is None
+
+
+async def test_inception_records_slugs_and_rejects_what_it_leaves_out(seeded):
+ """The inception list is the WHOLE answer. Detection found Android and
+ Go; the person says the project is Go and Python — so Android becomes a
+ recorded "no", and the next refresh cannot bring it back."""
+ owner, pid = seeded["owner"], seeded["pid"]
+ await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml", "go.mod"])
+ defaults = await inception_svc.current_defaults(owner, pid)
+ assert {"android-app", "go"} <= {p["slug"] for p in defaults["project_platforms"]}
+
+ out = await inception_svc.decide(owner, pid, via="ui", choices={
+ "seed_systems": False, "platforms": ["python", "go"],
+ })
+ assert _states(out["effects"]["platforms"]) == {
+ "android-app": "rejected", "go": "declared", "python": "declared",
+ }
+ async with async_session() as s:
+ project = await s.get(Project, pid)
+ assert project.inception["choices"]["platforms"] == ["go", "python"]
+ assert await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml"]) == []
+
+
+async def test_an_unknown_inception_platform_applies_nothing(seeded):
+ owner, pid = seeded["owner"], seeded["pid"]
+ with pytest.raises(ValueError, match="unknown platform"):
+ await inception_svc.decide(owner, pid, via="mcp", choices={
+ "seed_systems": True, "platforms": ["go", "cobol-mainframe"],
+ })
+ assert await platforms_svc.project_platforms(owner, pid) == []
+
+
+async def test_the_catalog_is_admin_written_and_duplicate_gated():
+ async with async_session() as s:
+ admin = await ensure_user(s, "platforms_admin", role="admin")
+ user = await ensure_user(s, OWNER)
+ await s.commit()
+ admin_id, user_id = admin.id, user.id
+ assert await platforms_svc.create_platform(user_id, "Kotlin Multiplatform") is None
+ # "Android App" reduces to the seeded android-app: the existing one comes
+ # back rather than a second spelling of it.
+ dup = await platforms_svc.create_platform(admin_id, "Android App")
+ assert dup["duplicate"] and dup["existing_id"] == await _id("android-app")
+ with pytest.raises(ValueError, match="repo-relative"):
+ await platforms_svc.create_platform(admin_id, "Odd", markers=["/abs"])
diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py
index 5e93f689..e40dd61e 100644
--- a/tests/test_mcp_tool_projects.py
+++ b/tests/test_mcp_tool_projects.py
@@ -25,6 +25,18 @@ def _no_systems():
yield
+@pytest.fixture(autouse=True)
+def _no_platforms():
+ """enter_project and get_project carry the project's platforms (milestone
+ 463). No database in this lane, so the lookup is stubbed to the common
+ case — a project with none yet. The populated shape is asserted in its
+ own test.
+ """
+ with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms",
+ AsyncMock(return_value=[])):
+ yield
+
+
@pytest.fixture(autouse=True)
def _no_coverage():
"""enter_project also reads the pattern-coverage cache (#2692) — same
diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py
index febea93a..eabdb007 100644
--- a/tests/test_milestone_summary_brief.py
+++ b/tests/test_milestone_summary_brief.py
@@ -24,6 +24,19 @@ PLAN = "A plan paragraph long enough to matter. " * 125 # ~5k chars
GOAL = "What the project is for. " * 50 # ~1.2k chars
+@pytest.fixture(autouse=True)
+def _no_platforms():
+ """enter_project and get_project carry the project's platforms (milestone
+ 463). No database in this lane, so the lookup is stubbed to the common
+ case — a project with none yet. The populated shape is asserted in its
+ own test.
+ """
+ with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms",
+ AsyncMock(return_value=[])):
+ yield
+
+
+
def _milestone(mid: int, status: str, touched_day: int) -> dict:
"""A summary row as get_project_milestone_summary returns it."""
touched = f"2026-08-{touched_day:02d}T00:00:00+00:00"
diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py
index ca67f79d..19c1edef 100644
--- a/tests/test_pattern_coverage.py
+++ b/tests/test_pattern_coverage.py
@@ -8,11 +8,9 @@ deliberately reuse the definitions test_write_path_trigger stages for the
hook; extending one detector means extending both, and this comment is the
tripwire.
"""
-import io
import json
import shutil
import subprocess
-import tarfile
from pathlib import Path
import pytest
@@ -29,7 +27,7 @@ from scribe.services.coverage import (
shapes_from_archive,
)
from scribe.services.shape_ledger import location_covers
-from tests.helpers import ensure_user
+from tests.helpers import ensure_user, forge_tarball
PLUGIN = Path(__file__).resolve().parents[1] / "plugin"
@@ -173,14 +171,7 @@ def test_scannable_gates_prose_vendored_and_sourcemaps():
# --- unit: reading shapes out of a forge tarball -----------------------------
-def _tarball(files: dict[str, bytes], top: str = "widget") -> bytes:
- buf = io.BytesIO()
- with tarfile.open(fileobj=buf, mode="w:gz") as tar:
- for path, data in files.items():
- info = tarfile.TarInfo(f"{top}/{path}")
- info.size = len(data)
- tar.addfile(info, io.BytesIO(data))
- return buf.getvalue()
+_tarball = forge_tarball # shared with tests/test_platforms.py
TREE = {
diff --git a/tests/test_platforms.py b/tests/test_platforms.py
new file mode 100644
index 00000000..71ecafc2
--- /dev/null
+++ b/tests/test_platforms.py
@@ -0,0 +1,145 @@
+"""Platforms without a database (milestone 463 step 2).
+
+Detection is pure — a catalog and a list of repo paths in, platform ids out —
+so it is pinned here against fixture trees. What it writes, and what it must
+never overwrite, is in tests/test_integration_platforms.py.
+"""
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
+from scribe.mcp.tools.projects import _inception_choices
+from scribe.services.coverage import scan_archive
+from tests.helpers import forge_tarball
+from scribe.services.platforms import (
+ MEMBER_STATES, SETTABLE_STATES, detect, marker_matches, members,
+ validate_markers, validate_updates,
+)
+
+
+def _platform(pid: int, *markers: str) -> SimpleNamespace:
+ return SimpleNamespace(id=pid, markers=list(markers))
+
+
+# --- a single marker ----------------------------------------------------------
+
+def test_a_bare_marker_matches_the_basename_anywhere_in_the_tree():
+ assert marker_matches("go.mod", "go.mod")
+ assert marker_matches("go.mod", "services/api/go.mod")
+ assert marker_matches("AndroidManifest.xml", "app/src/main/AndroidManifest.xml")
+ assert marker_matches("vite.config.*", "frontend/vite.config.ts")
+ assert not marker_matches("go.mod", "go.mod.bak")
+ assert not marker_matches("go.mod", "docs/go.mod.md")
+
+
+def test_a_marker_with_a_slash_matches_the_whole_path_only():
+ """A directory-shaped marker cannot be satisfied by a same-named file
+ somewhere else — `.github/workflows/*` is about the repo root's CI, not
+ a vendored copy of some other project's."""
+ assert marker_matches(".github/workflows/*", ".github/workflows/ci.yml")
+ assert not marker_matches(".github/workflows/*", "vendor/x/.github/workflows/ci.yml")
+ assert not marker_matches(".github/workflows/*", "ci.yml")
+
+
+def test_a_blank_marker_matches_nothing():
+ assert not marker_matches("", "anything")
+ assert not marker_matches(" ", "anything")
+
+
+# --- detection over a tree -------------------------------------------------------
+
+ANDROID_AND_GO = [
+ "app/build.gradle.kts",
+ "app/src/main/AndroidManifest.xml",
+ "server/go.mod",
+ "server/main.go",
+ "README.md",
+]
+
+
+def test_detect_finds_every_platform_a_tree_carries_and_nothing_else():
+ catalog = [
+ _platform(1, "AndroidManifest.xml"),
+ _platform(2, "go.mod"),
+ _platform(3, "Cargo.toml"),
+ _platform(4, ".github/workflows/*"),
+ ]
+ assert detect(catalog, ANDROID_AND_GO) == [1, 2]
+
+
+def test_a_platform_with_no_markers_is_declare_only():
+ """An empty marker list is how a platform says "a person must tell you" —
+ it must not match everything, and it must not match nothing by error."""
+ catalog = [_platform(1), _platform(2, "go.mod")]
+ assert detect(catalog, ANDROID_AND_GO) == [2]
+ assert detect([SimpleNamespace(id=9, markers=None)], ANDROID_AND_GO) == []
+
+
+def test_detect_on_an_empty_tree_finds_nothing():
+ assert detect([_platform(1, "go.mod")], []) == []
+
+
+def test_the_archive_scan_carries_every_path_not_only_the_scannable_ones():
+ """The markers that say what a project IS are mostly files the shape scan
+ skips — a manifest, a go.mod, a gradle script. Detection reads the scan's
+ paths, so a scan that listed only code files would detect nothing."""
+ scan = scan_archive(forge_tarball({
+ "app/src/main/AndroidManifest.xml": b"",
+ "server/go.mod": b"module x\n",
+ "server/main.py": b"def main():\n pass\n",
+ }))
+ assert set(scan.paths) == {
+ "app/src/main/AndroidManifest.xml", "server/go.mod", "server/main.py",
+ }
+ # The forge's wrapping directory is stripped, as it is for shapes.
+ assert not any(p.startswith("widget/") for p in scan.paths)
+
+
+# --- the answers a person may give -------------------------------------------------
+
+def test_detected_is_the_refreshs_to_write_never_a_persons():
+ assert "detected" in MEMBER_STATES
+ assert "detected" not in SETTABLE_STATES
+ assert "rejected" not in MEMBER_STATES
+
+
+def test_validate_updates():
+ assert validate_updates({"go": "declared", "rust": "rejected", "python": None}) is None
+ assert validate_updates({}) is None
+ assert "slug: state" in validate_updates(["go"])
+ assert "not a state a person sets" in validate_updates({"go": "detected"})
+ assert "not a state a person sets" in validate_updates({"go": "maybe"})
+ assert "named by its slug" in validate_updates({"": "declared"})
+
+
+def test_validate_markers():
+ assert validate_markers(None) is None
+ assert validate_markers(["go.mod", ".github/workflows/*"]) is None
+ assert "list of glob patterns" in validate_markers("go.mod")
+ assert "list of glob patterns" in validate_markers([1])
+ assert "repo-relative" in validate_markers(["/etc/passwd"])
+
+
+def test_members_drops_the_rejected_answers():
+ rows = [
+ {"id": 1, "slug": "go", "name": "Go", "state": "declared"},
+ {"id": 2, "slug": "rust", "name": "Rust", "state": "rejected"},
+ {"id": 3, "slug": "python", "name": "Python", "state": "detected"},
+ ]
+ assert [m["slug"] for m in members(rows)] == ["go", "python"]
+
+
+# --- the doors ------------------------------------------------------------------------
+
+def test_the_platform_tools_are_classified():
+ assert "list_platforms" in _READ_ONLY_TOOLS
+ assert "set_project_platforms" in _WRITE_TOOLS
+
+
+def test_inception_choices_carry_platforms_only_when_given():
+ """Left out, platforms stays UNSTATED (None after normalising) — which is
+ not the same answer as an empty list, "none of them"."""
+ assert "platforms" not in _inception_choices(0, True)
+ assert _inception_choices(0, True, ["go"])["platforms"] == ["go"]
+ assert _inception_choices(0, True, [])["platforms"] == []
diff --git a/tests/test_routes_platforms.py b/tests/test_routes_platforms.py
new file mode 100644
index 00000000..c7c9ad05
--- /dev/null
+++ b/tests/test_routes_platforms.py
@@ -0,0 +1,28 @@
+"""Structural tests for the platforms blueprint (milestone 463 step 2) —
+registration and the route-to-service contract. What the writes do is in
+tests/test_integration_platforms.py."""
+import inspect
+
+
+def test_platforms_blueprint_registered_in_app():
+ from scribe.app import create_app
+ app = create_app()
+ assert "platforms" in app.blueprints
+ rules = {(r.rule, m) for r in app.url_map.iter_rules() for m in r.methods}
+ for rule, method in (
+ ("/api/platforms", "GET"),
+ ("/api/platforms", "POST"),
+ ("/api/platforms/", "PATCH"),
+ ("/api/projects//platforms", "GET"),
+ ("/api/projects//platforms", "PUT"),
+ ):
+ assert (rule, method) in rules, f"{method} {rule} is not routed"
+
+
+def test_writes_to_the_catalog_and_to_a_project_take_the_caller():
+ """The catalog's admin gate and the project's write gate both live in the
+ service, so every write must be handed the caller to check."""
+ from scribe.services import platforms as svc
+ for name in ("create_platform", "update_platform", "set_project_platforms",
+ "project_platforms"):
+ assert "user_id" in inspect.signature(getattr(svc, name)).parameters
From 1774ee3696e9b4e39a2b5c195f1c2c0809bdfb71 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 09:58:41 -0400
Subject: [PATCH 03/10] fix(frontend): each shared
-
-
-
+
+
diff --git a/frontend/src/views/LessonDetailView.vue b/frontend/src/views/LessonDetailView.vue
index f9a159c4..4c5718f5 100644
--- a/frontend/src/views/LessonDetailView.vue
+++ b/frontend/src/views/LessonDetailView.vue
@@ -313,6 +313,11 @@ onMounted(load);
+
+
-
-
-
diff --git a/frontend/src/views/LessonEditorView.vue b/frontend/src/views/LessonEditorView.vue
index 475b5389..f3e47816 100644
--- a/frontend/src/views/LessonEditorView.vue
+++ b/frontend/src/views/LessonEditorView.vue
@@ -459,6 +459,11 @@ onMounted(() => {
+
+
-
-
-
diff --git a/tests/test_frontend_shared_styles.py b/tests/test_frontend_shared_styles.py
new file mode 100644
index 00000000..2c47b316
--- /dev/null
+++ b/tests/test_frontend_shared_styles.py
@@ -0,0 +1,53 @@
+"""A stylesheet shared by `\n',
+ "B.vue": '\n\n',
+ }
+ assert disagreements(sources) == {"@/x.css": {1: ["A.vue"], 2: ["B.vue"]}}
From 8aacc1824c25de11bf5ee36c1911a364c0c46c4a Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Tue, 6 Oct 2026 10:33:30 -0400
Subject: [PATCH 04/10] feat(family): the promotion engine - triggers, the
three criteria, the decision log and undo (milestone 463 step 3, #4989)
The agent promotes a family idea when all three criteria hold, and no person approves it. The criteria are product text: services/family.py states them, and the promote tool's docstring names every criterion the service enforces.
- Criteria: each one vetoes on its own when its reasoning is blank. Platform terms also needs an applies-when and a platform scope; proven also needs named evidence. A veto keeps the idea a candidate and is logged, so it becomes precedent.
- Precedent: every promotion stores the decisions on the nearest ideas by meaning, plus any the caller names.
- Promotion sets canon, the applicability test and the platform scope, and opens an unassessed ledger row for each member project the promoter can write. Re-promotion moves the version past every version the idea has held.
- Retire and undo: undo reverses only the latest idea-level decision, restores its recorded before-state, and logs itself with the undone decision as its precedent. Settled here: leaving canon closes the unassessed rows but keeps the judged ones, which read as needing a recheck after a re-promotion.
- Triggers open evaluations but never promote:
- a cross-project lineage citation ("matching #N") on a note or task write;
- a same-meaning record in another project on a shared platform, on create, at 0.80 (measured: the known pattern's builds scored 0.79-0.82, an unrelated project's best match 0.65);
- a milestone closing on a platform.
Each fails open and rides the response as family_hint.
- Doors: seven MCP tools, /api/family REST endpoints, and a Family page (nav, /family) showing the criteria, the ideas, and the decision log with undo and retire.
- utils/recordHref.ts holds the one copy of "where a record opens", now shared with LessonDetailView.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/api/family.ts | 78 ++
frontend/src/components/AppHeader.vue | 2 +
frontend/src/components/ProjectFamilyTab.vue | 1 +
frontend/src/router/index.ts | 7 +
frontend/src/utils/recordHref.ts | 9 +
frontend/src/views/FamilyView.vue | 360 ++++++++
frontend/src/views/LessonDetailView.vue | 11 +-
src/scribe/app.py | 2 +
src/scribe/mcp/server.py | 6 +
src/scribe/mcp/tools/__init__.py | 3 +-
src/scribe/mcp/tools/family.py | 183 ++++
src/scribe/mcp/tools/milestones.py | 13 +-
src/scribe/mcp/tools/notes.py | 6 +
src/scribe/mcp/tools/snippets.py | 4 +
src/scribe/mcp/tools/tasks.py | 6 +
src/scribe/routes/family.py | 125 +++
src/scribe/services/family.py | 838 +++++++++++++++++++
tests/conftest.py | 24 +
tests/test_family.py | 163 ++++
tests/test_integration_family_promotion.py | 293 +++++++
tests/test_routes_family.py | 29 +
21 files changed, 2151 insertions(+), 12 deletions(-)
create mode 100644 frontend/src/api/family.ts
create mode 100644 frontend/src/utils/recordHref.ts
create mode 100644 frontend/src/views/FamilyView.vue
create mode 100644 src/scribe/mcp/tools/family.py
create mode 100644 src/scribe/routes/family.py
create mode 100644 src/scribe/services/family.py
create mode 100644 tests/test_family.py
create mode 100644 tests/test_integration_family_promotion.py
create mode 100644 tests/test_routes_family.py
diff --git a/frontend/src/api/family.ts b/frontend/src/api/family.ts
new file mode 100644
index 00000000..9af9a96b
--- /dev/null
+++ b/frontend/src/api/family.ts
@@ -0,0 +1,78 @@
+/**
+ * Family canon (milestone 463): ideas every project on a platform shares, and
+ * the log of every decision about them.
+ *
+ * The agent promotes by written criteria — no approval step. This door is for
+ * reading what was decided and why, and for retiring an idea or undoing a
+ * decision when a person disagrees.
+ */
+import { apiGet, apiPost } from "@/api/client";
+
+export type IdeaStatus = "candidate" | "canon" | "retired";
+export type DecisionAction = "propose" | "promote" | "revise" | "retire" | "assess" | "undo";
+
+export interface Criterion {
+ key: string;
+ title: string;
+ test: string;
+}
+
+export interface FamilyIdea {
+ note_id: number;
+ status: IdeaStatus;
+ applies_when: string;
+ canon_version: number;
+ topic_id: number | null;
+ title: string;
+ note_type: string;
+ is_task: boolean;
+ project_id: number | null;
+ platforms: string[];
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+/** A state snapshot a decision records — slugs, never ids. */
+export interface IdeaSnapshot {
+ status: IdeaStatus;
+ applies_when: string;
+ canon_version: number;
+ platforms: string[];
+}
+
+export interface FamilyDecision {
+ id: number;
+ idea_id: number;
+ idea_title?: string;
+ project_id: number | null;
+ action: DecisionAction;
+ reason: string;
+ before: IdeaSnapshot | null;
+ after: IdeaSnapshot | null;
+ evidence: Record;
+ precedent_ids: number[];
+ decided_via: "agent" | "operator" | "system";
+ user_id: number | null;
+ created_at: string | null;
+ undoable?: boolean;
+}
+
+export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> {
+ const q = status ? `?status=${encodeURIComponent(status)}` : "";
+ return apiGet(`/api/family/ideas${q}`);
+}
+
+export async function listFamilyDecisions(limit = 50, offset = 0): Promise {
+ const data = await apiGet<{ decisions: FamilyDecision[] }>(
+ `/api/family/decisions?limit=${limit}&offset=${offset}`,
+ );
+ return data.decisions;
+}
+
+export async function undoFamilyDecision(id: number, reason: string) {
+ return apiPost<{ decision: FamilyDecision }>(`/api/family/decisions/${id}/undo`, { reason });
+}
+
+export async function retireFamilyIdea(noteId: number, reason: string) {
+ return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason });
+}
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index 0363f469..dfce0208 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -50,6 +50,7 @@ router.afterEach(() => {
ProjectsSnippetsRulebooks
+ Family