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] == [] -- 2.54.0 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. + + +

+
+ +
+

No design systems on this install yet — recording still settles the question.

@@ -132,6 +196,11 @@ onMounted(load); .inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; } .inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; } .inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); } +.inception-platforms { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: 0 1rem; +} .inception-select { padding: 0.45rem 0.7rem; border: 1px solid var(--fs-border-color); diff --git a/frontend/src/components/ProjectFamilyTab.vue b/frontend/src/components/ProjectFamilyTab.vue new file mode 100644 index 00000000..c76aef41 --- /dev/null +++ b/frontend/src/components/ProjectFamilyTab.vue @@ -0,0 +1,200 @@ + + + + + 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" }} + +

@@ -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) { + +
+
+

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. +

+ +
    +
  • + + +
  • +
+

+ 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 -- 2.54.0 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);
+ + - - - - - -\n\n diff --git a/frontend/src/views/LessonDetailView.vue b/frontend/src/views/LessonDetailView.vue index 4c5718f5..94d17389 100644 --- a/frontend/src/views/LessonDetailView.vue +++ b/frontend/src/views/LessonDetailView.vue @@ -41,6 +41,7 @@ import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight"; import { useToastStore } from "@/stores/toast"; import { renderMarkdown } from "@/utils/markdown"; import { canWriteRecord } from "@/utils/permission"; +import { recordHref } from "@/utils/recordHref"; const route = useRoute(); const router = useRouter(); @@ -59,14 +60,6 @@ const insightHtml = computed(() => lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "", ); -/** Where each source opens. A task and a note live at different routes, and a - * link that guesses wrong is worse than one that is plain. */ -function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) { - if (rec.is_task) return `/tasks/${rec.id}`; - if (rec.note_type === "snippet") return `/snippets/${rec.id}`; - if (rec.note_type === "lesson") return `/lessons/${rec.id}`; - return `/notes/${rec.id}`; -} const canWrite = computed(() => canWriteRecord(lesson.value?.permission)); @@ -193,7 +186,7 @@ onMounted(load);

Learned from

  • - + {{ rec.title }} {{ rec.status }} diff --git a/src/scribe/app.py b/src/scribe/app.py index a0d4c031..17f542ae 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -33,6 +33,7 @@ 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.family import family_bp from scribe.routes.lessons import lessons_bp from scribe.routes.snippets import snippets_bp from scribe.routes.webhooks import webhooks_bp @@ -103,6 +104,7 @@ def create_app() -> Quart: app.register_blueprint(systems_bp) app.register_blueprint(canonical_systems_bp) app.register_blueprint(platforms_bp) + app.register_blueprint(family_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 18520615..3bf22c3e 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -163,6 +163,9 @@ _READ_ONLY_TOOLS = frozenset({ # The platform catalog and a project's answers (milestone 463). A pure # read; set_project_platforms is the write. "list_platforms", + # Family canon (milestone 463): ideas, one idea with its precedents, and + # the decision log. Reads of records the caller can read. + "list_family_ideas", "get_family_idea", "list_family_decisions", # 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. @@ -187,6 +190,9 @@ _WRITE_TOOLS = frozenset({ "create_project", "update_project", "delete_project", "decide_project_inception", "create_system", "update_system", "delete_system", "map_system_to_canonical", "set_project_platforms", + # family canon — the promotion engine + "propose_family_idea", "promote_family_idea", "retire_family_idea", + "undo_family_decision", "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 2bc5704b..8fd299f7 100644 --- a/src/scribe/mcp/tools/__init__.py +++ b/src/scribe/mcp/tools/__init__.py @@ -5,7 +5,7 @@ to an MCPServer instance. `register_all(mcp)` is the single entry point called from `mcp.server.build_mcp_server`. """ from scribe.mcp.tools import ( - design_systems, lessons, milestones, notes, processes, projects, recent, repos, + design_systems, family, lessons, milestones, notes, processes, projects, recent, repos, moments, platforms, retrieval_review, retrieval_tuning, wide_net, rulebooks, search, shapes, snippets, systems, tags, tasks, trash, @@ -25,6 +25,7 @@ def register_all(mcp) -> None: milestones.register(mcp) systems.register(mcp) platforms.register(mcp) + family.register(mcp) design_systems.register(mcp) tags.register(mcp) recent.register(mcp) diff --git a/src/scribe/mcp/tools/family.py b/src/scribe/mcp/tools/family.py new file mode 100644 index 00000000..291d8a2b --- /dev/null +++ b/src/scribe/mcp/tools/family.py @@ -0,0 +1,183 @@ +"""Family canon MCP tools — the promotion engine's agent door (milestone 463). + +Thin wrappers over services/family.py. The agent is the decider here: no +person approves a promotion, so the criteria are stated in these docstrings +and enforced by the service, and every decision is logged with its reason. +""" +from __future__ import annotations + +from scribe.mcp._context import current_user_id +from scribe.services import family as family_svc + + +async def list_family_ideas( + status: str = "", platform: str = "", limit: int = 50, offset: int = 0, +) -> dict: + """Family ideas — records that every project on a platform shares — with + their state: `candidate` (waiting to be evaluated, or held back by a + criterion), `canon` (promoted; every project on its platforms answers it) + or `retired`. + + Args: + status: candidate | canon | retired. Empty = all. + platform: a platform slug (list_platforms). Empty = all. + limit / offset: page through the list. + """ + ideas = await family_svc.list_ideas( + current_user_id(), status=status or None, platform=platform or None, + limit=max(1, min(limit, 200)), offset=max(0, offset), + ) + return {"ideas": ideas, "limit": limit, "offset": offset} + + +async def get_family_idea(note_id: int) -> dict: + """One family idea with what an evaluation needs: its state, platforms, + full decision history, ledger counts, THE THREE CRITERIA, and the + `precedents` — the decisions on the ideas nearest this one by meaning. + Read the precedents before deciding, and decide consistently with them + unless this idea differs in a way you can name. + + Args: + note_id: the idea's record id. + """ + uid = current_user_id() + idea = await family_svc.get_idea(uid, note_id) + if idea is None: + raise ValueError(f"#{note_id} is not a family idea you can read") + idea["precedents"] = await family_svc.precedents(uid, note_id) + return idea + + +async def propose_family_idea(note_id: int, reason: str, applies_when: str = "") -> dict: + """Record a note, snippet or lesson as a family-idea CANDIDATE: something + you believe every project on some platform will face. It reaches no + project's ledger until it is promoted. + + Proposing is cheap and does not promote. When the record already meets + the three criteria, call promote_family_idea directly instead. + + Args: + note_id: the record that carries the idea. + reason: why this looks like a family idea. + applies_when: optional draft of when it applies, in platform terms. + """ + idea, created = await family_svc.propose( + current_user_id(), note_id, reason=reason, applies_when=applies_when or None, + ) + return {"idea": idea, "created": created} + + +async def promote_family_idea( + note_id: int, + applies_when: str, + platforms: list[str], + platform_terms: str, + platform_problem: str, + proven: str, + evidence: list[str], + reason: str, + precedent_ids: list[int] | None = None, +) -> dict: + """Evaluate a record against the three criteria and, if all hold, promote + it to family canon. YOU decide; nobody approves. The record of why is what + makes the decision reviewable and the next one consistent. + + An idea is promoted only when ALL THREE hold, and each is answered with + your reasoning: + + 1. platform_terms — its 'when it applies' is stated in terms of a + platform (what a project is built on or ships as), not one app's + domain. Any project on the platform could read it and know whether it + applies. + 2. platform_problem — it answers a problem the platform itself causes, or + a stance the operator holds across projects. One app's preference does + not qualify. + 3. proven — it has worked for real at least once (CI green, verified on a + device, shipped). Name that in `evidence`. + + Any criterion left blank — or `applies_when`, `platforms` or `evidence` + left empty — VETOES the promotion on its own. A veto is not an error: the + idea stays a candidate and the veto is logged, so the next evaluation of + something like it sees why this one was held. That is also how you record + "evaluated, and it does not qualify": leave the failing criterion blank + and say why in `reason`. + + Before deciding, read get_family_idea's `precedents`. The engine also + finds the nearest earlier decisions itself and stores them as consulted. + + On promotion every project that is on one of `platforms` and that you can + write gets an `unassessed` row in its adoption ledger. If the idea was + canon before, its version moves, so earlier answers read as needing a + recheck. + + Args: + note_id: the record that carries the idea. + applies_when: when it applies, in platform terms. + platforms: platform slugs it is for (list_platforms). + platform_terms: why criterion 1 holds (blank = it does not). + platform_problem: why criterion 2 holds (blank = it does not). + proven: why criterion 3 holds (blank = it does not). + evidence: what proved it — a CI run, a task, a commit, a device check. + reason: the decision in one or two sentences. + precedent_ids: earlier family decisions you followed, if any. + """ + return await family_svc.promote( + current_user_id(), note_id, + applies_when=applies_when, platforms=platforms or [], + criteria={ + "platform_terms": platform_terms, + "platform_problem": platform_problem, + "proven": proven, + }, + evidence=evidence or [], reason=reason, precedent_ids=precedent_ids or [], + ) + + +async def retire_family_idea(note_id: int, reason: str) -> dict: + """Demote a family idea — it no longer applies, or a better one replaces + it. Its history and every judged ledger answer are kept; rows nobody had + judged yet are closed. Undoable with undo_family_decision. + + Args: + note_id: the idea. + reason: why it is retired. + """ + return await family_svc.retire(current_user_id(), note_id, reason=reason) + + +async def undo_family_decision(decision_id: int, reason: str) -> dict: + """Reverse a family decision — a promotion, a retirement or a proposal — + restoring the state it recorded as `before`. Only the latest idea-level + decision on an idea can be undone; the undo is logged too, so the history + keeps both. + + Args: + decision_id: the decision (list_family_decisions). + reason: why it is undone. + """ + return await family_svc.undo(current_user_id(), decision_id, reason=reason) + + +async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict: + """The family decision log, newest first: every proposal, promotion, + veto, retirement and undo, with its reason, evidence and the precedents + it followed. `undoable` marks the decision an undo would reverse. + + Args: + note_id: only this idea's decisions. 0 = all. + limit / offset: page through the log. + """ + rows = await family_svc.list_decisions( + current_user_id(), idea_id=note_id or None, + limit=max(1, min(limit, 200)), offset=max(0, offset), + ) + return {"decisions": rows, "limit": limit, "offset": offset} + + +def register(mcp) -> None: + for fn in ( + list_family_ideas, get_family_idea, propose_family_idea, + promote_family_idea, retire_family_idea, undo_family_decision, + list_family_decisions, + ): + mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/mcp/tools/milestones.py b/src/scribe/mcp/tools/milestones.py index 367e72aa..66f17aa9 100644 --- a/src/scribe/mcp/tools/milestones.py +++ b/src/scribe/mcp/tools/milestones.py @@ -13,6 +13,7 @@ from __future__ import annotations from scribe.mcp._context import current_user_id from scribe.services import dedup as dedup_svc +from scribe.services import family as family_svc from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc from scribe.services import task_logs as task_logs_svc @@ -172,12 +173,20 @@ async def update_milestone( if order_index >= 0: fields["order_index"] = order_index await refuse_guessed_ids(title, description, body) + # Asked BEFORE the write: only the transition into done is a closing. + closing = status == "done" and await family_svc.milestone_is_open(uid, milestone_id) milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields) if milestone is None: raise ValueError(f"milestone {milestone_id} not found") + data = milestone.to_dict() + if closing: + # Family canon's milestone trigger (milestone 463): a plan closing on + # a platform is the moment to ask whether what it built is shared. + hint = await family_svc.milestone_trigger(uid, milestone) + if hint: + data["family_hint"] = hint return await moment_delivery.attach_moment_rules( - uid, "update_milestone", {"status": status, "project_id": project_id}, - milestone.to_dict(), + uid, "update_milestone", {"status": status, "project_id": project_id}, data, ) diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index 09e7e41b..94334c66 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id from scribe.mcp.tools import systems as systems_tools from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc +from scribe.services import family as family_svc from scribe.services import notes as notes_svc from scribe.services import supersession as supersession_svc from scribe.services import systems as systems_svc @@ -237,6 +238,9 @@ async def create_note( await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) await supersession_svc.attach_relations(uid, note.id, data, hint=True) data.update(dedup_svc.note_overlap_response(overlaps, "note")) + # Family canon's write-time triggers (milestone 463): a cited source of a + # pattern, or the same idea in another project on a shared platform. + await family_svc.attach_family_hint(uid, data, note, created=True) return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data) @@ -319,6 +323,8 @@ async def update_note( uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id ) await supersession_svc.attach_relations(uid, note_id, data, hint=True) + if body: + await family_svc.attach_family_hint(uid, data, note, created=False) return data diff --git a/src/scribe/mcp/tools/snippets.py b/src/scribe/mcp/tools/snippets.py index 1266d9de..27015be1 100644 --- a/src/scribe/mcp/tools/snippets.py +++ b/src/scribe/mcp/tools/snippets.py @@ -14,6 +14,7 @@ from scribe.mcp._context import current_user_id from scribe.mcp.tools import systems as systems_tools from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc +from scribe.services import family as family_svc from scribe.services import snippets as snippets_svc from scribe.services.note_usage import attach_usage, record_pulled from scribe.services import systems as systems_svc @@ -224,6 +225,9 @@ async def create_snippet( advice = snippets_svc.trigger_advice(when_to_use) if advice: data["trigger_advice"] = advice + # Family canon (milestone 463): the same shape recorded in another project + # on a shared platform opens an evaluation of the earlier one. + await family_svc.attach_family_hint(uid, data, note, created=True) return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data) diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index ab460a51..247b93a1 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -28,6 +28,7 @@ from scribe.mcp._context import current_user_id from scribe.mcp.tools import systems as systems_tools from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc +from scribe.services import family as family_svc from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc # Imported by NAME, not reached through notes_svc: minted_kind is pure @@ -363,6 +364,9 @@ async def create_task( data = note.to_dict() await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None) data.update(dedup_svc.note_overlap_response(overlaps, "task")) + # A task that says it is "matching" another project's work names the + # source of a pattern — family canon's citation trigger (milestone 463). + await family_svc.attach_family_hint(uid, data, note, created=True) return await placement_svc.attach_placement(uid, data, note) @@ -457,6 +461,8 @@ async def update_task( uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id ) await placement_svc.attach_placement(uid, data, note) + if body: + await family_svc.attach_family_hint(uid, data, note, created=False) if status in _CLOSING_STATUSES: data["report_back"] = REPORT_BACK_CUE # The operator's own adjustments to the completion report, retrieved diff --git a/src/scribe/routes/family.py b/src/scribe/routes/family.py new file mode 100644 index 00000000..847750c8 --- /dev/null +++ b/src/scribe/routes/family.py @@ -0,0 +1,125 @@ +"""Family canon routes — the web door to the promotion engine (milestone 463). + +The agent decides promotions through the MCP tools; this door exists so a +person can READ every decision and its reasons, and retire an idea or undo a +decision when they disagree. The promote and propose endpoints are here for +parity with the agent's door (rule 33), and are recorded as the operator's. +Every write is gated on the idea's note in the service (rule 78). +""" +import logging + +from quart import Blueprint, jsonify, request + +from scribe.auth import get_current_user_id, login_required +from scribe.routes.utils import not_found +from scribe.services import family as family_svc + +logger = logging.getLogger(__name__) + +family_bp = Blueprint("family", __name__, url_prefix="/api/family") + + +def _page() -> tuple[int, int]: + try: + limit = max(1, min(int(request.args.get("limit", 50)), 200)) + offset = max(0, int(request.args.get("offset", 0))) + except ValueError: + limit, offset = 50, 0 + return limit, offset + + +@family_bp.route("/ideas", methods=["GET"]) +@login_required +async def list_ideas_route(): + limit, offset = _page() + ideas = await family_svc.list_ideas( + get_current_user_id(), + status=request.args.get("status") or None, + platform=request.args.get("platform") or None, + limit=limit, offset=offset, + ) + return jsonify({"ideas": ideas, "criteria": list(family_svc.CRITERIA)}) + + +@family_bp.route("/ideas/", methods=["GET"]) +@login_required +async def get_idea_route(note_id: int): + uid = get_current_user_id() + idea = await family_svc.get_idea(uid, note_id) + if idea is None: + return not_found("Family idea") + idea["precedents"] = await family_svc.precedents(uid, note_id) + return jsonify(idea) + + +@family_bp.route("/ideas//propose", methods=["POST"]) +@login_required +async def propose_route(note_id: int): + data = await request.get_json() or {} + try: + idea, created = await family_svc.propose( + get_current_user_id(), note_id, reason=data.get("reason") or "", + applies_when=data.get("applies_when") or None, decided_via="operator", + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"idea": idea, "created": created}), (201 if created else 200) + + +@family_bp.route("/ideas//promote", methods=["POST"]) +@login_required +async def promote_route(note_id: int): + data = await request.get_json() or {} + try: + result = await family_svc.promote( + get_current_user_id(), note_id, + applies_when=data.get("applies_when") or "", + platforms=data.get("platforms") or [], + criteria=data.get("criteria") or {}, + evidence=data.get("evidence") or [], + reason=data.get("reason") or "", + precedent_ids=data.get("precedent_ids") or [], + decided_via="operator", + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(result) + + +@family_bp.route("/ideas//retire", methods=["POST"]) +@login_required +async def retire_route(note_id: int): + data = await request.get_json() or {} + try: + result = await family_svc.retire( + get_current_user_id(), note_id, reason=data.get("reason") or "", + decided_via="operator", + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(result) + + +@family_bp.route("/decisions", methods=["GET"]) +@login_required +async def list_decisions_route(): + limit, offset = _page() + idea_id = request.args.get("idea_id", type=int) + rows = await family_svc.list_decisions( + get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset, + ) + return jsonify({"decisions": rows, "limit": limit, "offset": offset}) + + +@family_bp.route("/decisions//undo", methods=["POST"]) +@login_required +async def undo_route(decision_id: int): + data = await request.get_json() or {} + try: + result = await family_svc.undo( + get_current_user_id(), decision_id, reason=data.get("reason") or "", + decided_via="operator", + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(result) diff --git a/src/scribe/services/family.py b/src/scribe/services/family.py new file mode 100644 index 00000000..88f3d36a --- /dev/null +++ b/src/scribe/services/family.py @@ -0,0 +1,838 @@ +"""Family canon's promotion engine (milestone 463 step 3). + +A family idea moves between three states — candidate, canon, retired — and +every move is a decision with a reason, written to `family_decisions`. No +person approves a promotion: the agent decides against the written criteria +below, and the log is what keeps one decision consistent with the last similar +one, and what lets a person read or undo any of them afterwards. + +WHO DECIDES WHAT + +- TRIGGERS (`citation_trigger`, `repeat_trigger`, `milestone_trigger`) only + ever OPEN an evaluation. The first two record the source record as a + `candidate` (decided_via "system") and hand the writer an in-band hint; a + closed milestone is not a record an idea can hang on, so it only hints. None + of them promotes. +- THE AGENT evaluates a candidate against the three criteria and either + promotes it or leaves it a candidate with the reason. A criterion with no + support vetoes on its own; the veto is logged too, because a held candidate + is precedent for the next one like it. +- A PERSON reads the log, and may retire an idea or undo a decision from the + web door. Those are the only operator acts, and neither is required. + +PRECEDENT + +Every promotion records the earlier decisions it was consistent with. The +engine finds them itself — the decisions on the ideas nearest this one by +meaning — and stores them alongside any the caller names, so "which precedents +were consulted" is a fact about the call, not a sentence a session might skip. + +LEDGER ROWS AND UNDO (settled here, as the milestone asked) + +Promotion opens an `unassessed` row for every project that is a member of one +of the idea's platforms and that the promoter can write. Leaving canon — +by retirement, or by undoing the promotion — deletes the `unassessed` rows, +because nobody judged them and they would only be noise. Rows somebody DID +judge (adopted, variant, exempt, owed) are kept: each is a decision with its +reason, and if the idea is promoted again its version moves, so every kept +row reads as needing a recheck rather than as still agreeing. +""" +from __future__ import annotations + +import logging +import re +from datetime import datetime, timezone + +from sqlalchemy import delete, func, select + +from scribe.models import async_session +from scribe.models.family import ( + FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, Platform, + ProjectPlatform, +) +from scribe.models.note import Note +from scribe.models.project import Project +from scribe.services import access + +logger = logging.getLogger(__name__) + +# --- the criteria, as product text -------------------------------------------- +# +# These are what an evaluation is judged against, on every install. They live +# here — and in the tool docstrings that quote them — rather than in any +# instance's rulebook (rules 115, 119). + +CRITERIA = ( + { + "key": "platform_terms", + "title": "Stated in platform terms", + "test": ( + "Its 'when it applies' is stated in terms of a platform — what the " + "project is built on or ships as — not one app's domain. Any project " + "on the platform could read it and know whether it applies." + ), + }, + { + "key": "platform_problem", + "title": "Answers a platform problem or a cross-project stance", + "test": ( + "It answers a problem the platform itself causes, or a stance the " + "operator holds across projects. One app's preference does not " + "qualify." + ), + }, + { + "key": "proven", + "title": "Proven at least once", + "test": ( + "It has worked for real at least once — CI green, verified on a " + "device, or shipped — and the evidence is named. An unproven idea " + "stays a candidate." + ), + }, +) +CRITERIA_KEYS = tuple(c["key"] for c in CRITERIA) + +TRIGGERS = ("citation", "repeat", "milestone") + +# The repeat trigger's similarity floor. MEASURED, not guessed (2026-10-06, +# #4989): against a description of one pattern known to have been built four +# times in four projects, the records that implement it scored 0.79-0.82, and +# the best match in a project that never built it scored 0.65. A trigger only +# opens an evaluation, so a false positive costs one judgment, not a wrong +# promotion. Step 5 measures the cross-language threshold against known pairs +# and supersedes this. +REPEAT_THRESHOLD = 0.80 + +# Words that say a citation is the SOURCE of a pattern rather than background. +# "see #12" is a pointer; "matching #12" and "ported from #12" are lineage. +_LINEAGE = re.compile( + r"\b(match(?:es|ing)?|mirror(?:s|ing|ed)?|same (?:shape|pattern|approach|design) as|" + r"ported from|copied from|borrowed from|lifted from|taken from|based on|" + r"modell?ed on|follow(?:s|ing)? the (?:shape|pattern|approach) of|as (?:built|done) in|" + r"reuses?|re-?implement(?:s|ing)?|like)\b", + re.I, +) +# How far before a `#N` the lineage word may sit: the same clause, roughly. +_LINEAGE_WINDOW = 80 + +# Note kinds the repeat trigger compares: recorded knowledge and shapes, not +# the to-do list. +_REPEAT_KINDS = ("snippet", "note") + +MEMBER_STATES = ("declared", "detected") +_IDEA_ACTIONS = ("propose", "promote", "revise", "retire") + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +# --- reads -------------------------------------------------------------------- + +async def _platform_slugs(session, note_id: int) -> list[str]: + rows = await session.execute( + select(Platform.slug) + .join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == Platform.id) + .where(FamilyIdeaPlatform.note_id == note_id) + .order_by(Platform.order_index.asc(), Platform.slug.asc()) + ) + return list(rows.scalars().all()) + + +async def _snapshot(session, idea: FamilyIdea | None) -> dict | None: + """The idea's state, as a decision's before/after. Slugs, never ids: an id + inside JSON cannot be remapped by a restore (the #3182 trap).""" + if idea is None: + return None + return { + "status": idea.status, + "applies_when": idea.applies_when or "", + "canon_version": idea.canon_version, + "platforms": await _platform_slugs(session, idea.note_id), + } + + +def _decision_dict(d: FamilyDecision, title: str | None = None) -> dict: + out = d.to_dict() + if title is not None: + out["idea_title"] = title + return out + + +async def get_idea(user_id: int, note_id: int) -> dict | None: + """One idea with everything an evaluation reads: its state, platforms, + decision history (newest first), its ledger counts and the criteria. + None when the caller cannot read the note or it is not an idea.""" + if not await access.can_read_note(user_id, note_id): + return None + async with async_session() as session: + idea = await session.get(FamilyIdea, note_id) + note = await session.get(Note, note_id) + if idea is None or note is None: + return None + decisions = (await session.execute( + select(FamilyDecision).where(FamilyDecision.idea_id == note_id) + .order_by(FamilyDecision.id.desc()) + )).scalars().all() + counts = dict((await session.execute( + select(FamilyAdoption.status, func.count()) + .where(FamilyAdoption.idea_id == note_id) + .group_by(FamilyAdoption.status) + )).all()) + out = idea.to_dict() + out.update({ + "title": note.title, + "note_type": note.note_type or "note", + "is_task": note.is_task, + "project_id": note.project_id, + "platforms": await _platform_slugs(session, note_id), + "adoptions": counts, + "decisions": [_decision_dict(d) for d in decisions], + "undoable_decision_id": _undoable_id(decisions), + }) + out["criteria"] = list(CRITERIA) + return out + + +async def list_ideas( + user_id: int, *, status: str | None = None, platform: str | None = None, + limit: int = 50, offset: int = 0, +) -> list[dict]: + """Ideas whose note the caller can read, newest change first.""" + async with async_session() as session: + query = ( + select(FamilyIdea, Note.title, Note.note_type, Note.project_id, Note.status) + .join(Note, Note.id == FamilyIdea.note_id) + .where(access.readable_notes_clause(user_id), Note.deleted_at.is_(None)) + ) + if status: + query = query.where(FamilyIdea.status == status) + if platform: + query = query.where(FamilyIdea.note_id.in_( + select(FamilyIdeaPlatform.note_id) + .join(Platform, Platform.id == FamilyIdeaPlatform.platform_id) + .where(Platform.slug == platform) + )) + rows = (await session.execute( + query.order_by(FamilyIdea.updated_at.desc()).limit(limit).offset(offset) + )).all() + out = [] + for idea, title, note_type, project_id, note_status in rows: + d = idea.to_dict() + d.update({ + "title": title, + "note_type": note_type or "note", + "is_task": note_status is not None, + "project_id": project_id, + "platforms": await _platform_slugs(session, idea.note_id), + }) + out.append(d) + return out + + +async def list_decisions( + user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0, +) -> list[dict]: + """The decision log, newest first, over ideas the caller can read. Each + row says whether it is the one an undo would reverse.""" + async with async_session() as session: + query = ( + select(FamilyDecision, Note.title) + .join(Note, Note.id == FamilyDecision.idea_id) + .where(access.readable_notes_clause(user_id)) + ) + if idea_id: + query = query.where(FamilyDecision.idea_id == idea_id) + rows = (await session.execute( + query.order_by(FamilyDecision.id.desc()).limit(limit).offset(offset) + )).all() + latest = await _latest_idea_decisions(session, {d.idea_id for d, _ in rows}) + out = [] + for d, title in rows: + item = _decision_dict(d, title) + item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d) + out.append(item) + return out + + +def _can_undo(d: FamilyDecision) -> bool: + """An idea-level decision that changed something. A veto (a `propose` + whose before and after agree) changed nothing, and an undo is undone by + deciding again, not by undoing the undo.""" + return d.project_id is None and d.action in _IDEA_ACTIONS and d.before != d.after + + +def _undoable_id(decisions_newest_first) -> int | None: + for d in decisions_newest_first: + if d.project_id is None: + return d.id if _can_undo(d) else None + return None + + +async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]: + if not idea_ids: + return {} + rows = await session.execute( + select(FamilyDecision.idea_id, func.max(FamilyDecision.id)) + .where(FamilyDecision.idea_id.in_(idea_ids), FamilyDecision.project_id.is_(None)) + .group_by(FamilyDecision.idea_id) + ) + return dict(rows.all()) + + +async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]: + """The decisions on the ideas nearest this one by meaning — what a new + decision about it should be consistent with. Each idea contributes its + latest idea-level decision. Empty when nothing similar has been decided, + or when the embedder is unavailable.""" + from scribe.services.embeddings import embedding_text, semantic_search_notes + + async with async_session() as session: + note = await session.get(Note, note_id) + if note is None: + return [] + idea_ids = set((await session.execute(select(FamilyIdea.note_id))).scalars().all()) + idea_ids.discard(note_id) + if not idea_ids: + return [] + try: + hits = await semantic_search_notes( + user_id, embedding_text(note.title, note.body), exclude_ids={note_id}, + limit=40, threshold=0.0, scope="read", include_global_kinds=True, + demote_superseded=False, + ) + except Exception: + logger.warning("precedent search failed for idea %s", note_id, exc_info=True) + return [] + ranked = [(score, n) for score, n in hits if n.id in idea_ids][:limit] + if not ranked: + return [] + async with async_session() as session: + latest = await _latest_idea_decisions(session, {n.id for _, n in ranked}) + decisions = { + d.id: d for d in (await session.execute( + select(FamilyDecision).where(FamilyDecision.id.in_(list(latest.values()))) + )).scalars().all() + } + out = [] + for score, n in ranked: + d = decisions.get(latest.get(n.id)) + if d is not None: + item = _decision_dict(d, n.title) + item["similarity"] = round(float(score), 3) + out.append(item) + return out + + +# --- writes --------------------------------------------------------------------- + +def _log(session, *, idea_id: int, action: str, reason: str, before, after, + evidence: dict | None, precedent_ids: list[int] | None, decided_via: str, + user_id: int | None, project_id: int | None = None) -> FamilyDecision: + row = FamilyDecision( + idea_id=idea_id, project_id=project_id, action=action, reason=reason.strip(), + before=before, after=after, evidence=evidence or {}, + precedent_ids=list(dict.fromkeys(precedent_ids or [])), + decided_via=decided_via, user_id=user_id, + ) + session.add(row) + return row + + +async def _resolve_platforms(session, slugs: list[str]) -> dict[str, int]: + wanted = list(dict.fromkeys(s.strip() for s in slugs if s and s.strip())) + if not wanted: + return {} + rows = (await session.execute( + select(Platform.slug, Platform.id) + .where(Platform.slug.in_(wanted), Platform.deleted_at.is_(None)) + )).all() + known = dict(rows) + unknown = [s for s in wanted if s not in known] + if unknown: + raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)") + return known + + +async def _set_platforms(session, note_id: int, platform_ids) -> None: + await session.execute(delete(FamilyIdeaPlatform).where(FamilyIdeaPlatform.note_id == note_id)) + for pid in dict.fromkeys(platform_ids): + session.add(FamilyIdeaPlatform(note_id=note_id, platform_id=pid)) + + +async def _open_ledger(session, user_id: int, note_id: int) -> int: + """An `unassessed` row for every member project of the idea's platforms + that the promoter can write and that has no row yet. Returns how many.""" + project_ids = set((await session.execute( + select(ProjectPlatform.project_id) + .join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id) + .join(Project, Project.id == ProjectPlatform.project_id) + .where( + FamilyIdeaPlatform.note_id == note_id, + ProjectPlatform.state.in_(MEMBER_STATES), + Project.deleted_at.is_(None), + ) + )).scalars().all()) + answered = set((await session.execute( + select(FamilyAdoption.project_id).where(FamilyAdoption.idea_id == note_id) + )).scalars().all()) + opened = 0 + for pid in sorted(project_ids - answered): + # Rule 78: a promotion reaches only the projects its promoter could + # have written an answer into themselves. + if await access.can_write_project(user_id, pid): + session.add(FamilyAdoption(project_id=pid, idea_id=note_id, status="unassessed")) + opened += 1 + return opened + + +async def _close_unassessed(session, note_id: int) -> int: + result = await session.execute( + delete(FamilyAdoption).where( + FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed", + ) + ) + return result.rowcount or 0 + + +async def propose( + user_id: int, note_id: int, *, reason: str, trigger: str = "agent", + evidence: dict | None = None, applies_when: str | None = None, + decided_via: str = "agent", +) -> tuple[dict, bool]: + """Record a note as a family-idea CANDIDATE. Returns (idea, created). + + Idempotent: an existing idea comes back unchanged and nothing is logged — + a trigger firing on every edit of a record must not grow the log. Write- + gated on the note: an idea is state on someone's record (rule 78). + """ + if not (reason or "").strip(): + raise ValueError("a proposal needs a reason — what makes this a family idea?") + if not await access.can_write_note(user_id, note_id): + raise ValueError(f"note {note_id} not found or no write access") + async with async_session() as session: + idea = await session.get(FamilyIdea, note_id) + if idea is not None: + return idea.to_dict(), False + idea = FamilyIdea(note_id=note_id, status="candidate", + applies_when=(applies_when or "").strip() or None) + session.add(idea) + await session.flush() + _log( + session, idea_id=note_id, action="propose", reason=reason, before=None, + after=await _snapshot(session, idea), + evidence={"trigger": trigger, **(evidence or {})}, + precedent_ids=None, decided_via=decided_via, user_id=user_id, + ) + await session.commit() + return idea.to_dict(), True + + +def vetoes(*, applies_when: str, platforms: list[str], criteria: dict, evidence: list) -> list[str]: + """The criteria a promotion fails, by key. Pure. + + Each fails ON ITS OWN when its support is missing: + - platform_terms — no reasoning, no `applies_when`, or no platform scope; + - platform_problem — no reasoning; + - proven — no reasoning, or no named evidence. + """ + def said(key: str) -> bool: + return bool(str(criteria.get(key) or "").strip()) + + failed = [] + if not said("platform_terms") or not (applies_when or "").strip() or not platforms: + failed.append("platform_terms") + if not said("platform_problem"): + failed.append("platform_problem") + if not said("proven") or not [e for e in evidence or [] if str(e).strip()]: + failed.append("proven") + return failed + + +async def promote( + user_id: int, note_id: int, *, applies_when: str, platforms: list[str], + criteria: dict, evidence: list[str], reason: str, + precedent_ids: list[int] | None = None, decided_via: str = "agent", +) -> dict: + """Evaluate a record against the three criteria and promote it to canon. + + A record nobody proposed may be promoted directly — the candidate row is + created on the way. Any criterion without support vetoes the promotion: + the idea stays (or becomes) a candidate and the veto is logged as a + `propose` decision naming what failed, so it is precedent too. + + On promotion: status canon, `applies_when` and the platform scope set, the + version bumped if this idea has been promoted before (so rows judged + against the earlier canon read as needing a recheck), an `unassessed` + ledger row opened per member project, and a decision logged with the + criteria reasoning, the evidence and the precedents consulted. + + Raises ValueError on a malformed call (no reason, unknown platform, no + write access, already canon) — before anything is written. + """ + if not (reason or "").strip(): + raise ValueError("a promotion needs a reason") + if not await access.can_write_note(user_id, note_id): + raise ValueError(f"note {note_id} not found or no write access") + criteria = {k: str((criteria or {}).get(k) or "").strip() for k in CRITERIA_KEYS} + evidence = [str(e).strip() for e in evidence or [] if str(e).strip()] + consulted = await precedents(user_id, note_id) + named = await _existing_decision_ids(precedent_ids or []) + precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted])) + + async with async_session() as session: + known = await _resolve_platforms(session, platforms or []) + idea = await session.get(FamilyIdea, note_id) + if idea is not None and idea.status == "canon": + raise ValueError( + f"#{note_id} is already canon (version {idea.canon_version}); " + "retire it first, or record a revision" + ) + created_now = idea is None + if created_now: + idea = FamilyIdea(note_id=note_id, status="candidate") + session.add(idea) + await session.flush() + # A row made by this call had no prior state: `before` says so, which + # also makes a veto that created a candidate undoable (it changed + # something — a record became a candidate). + before = None if created_now else await _snapshot(session, idea) + failed = vetoes( + applies_when=applies_when, platforms=list(known), criteria=criteria, + evidence=evidence, + ) + record = {"criteria": criteria, "evidence": evidence} + if failed: + decision = _log( + session, idea_id=note_id, action="propose", + reason=f"held as a candidate — fails {', '.join(failed)}: {reason.strip()}", + before=before, after=await _snapshot(session, idea), + evidence={**record, "vetoed_by": failed}, + precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + return { + "promoted": False, "vetoed_by": failed, "idea": idea.to_dict(), + "decision": decision.to_dict(), "precedents": consulted, + } + + # Re-promotion moves the version PAST every version this idea has ever + # held — including one an undo rolled back from — so no row judged + # against an earlier canon can read as agreeing with this one. + history = (await session.execute( + select(FamilyDecision.action, FamilyDecision.after) + .where(FamilyDecision.idea_id == note_id) + )).all() + if any(action == "promote" for action, _ in history): + seen = [idea.canon_version or 1] + [ + int(after.get("canon_version") or 1) for _, after in history if after + ] + idea.canon_version = max(seen) + 1 + idea.status = "canon" + idea.applies_when = applies_when.strip() + idea.updated_at = _now() + await _set_platforms(session, note_id, known.values()) + await session.flush() + opened = await _open_ledger(session, user_id, note_id) + decision = _log( + session, idea_id=note_id, action="promote", reason=reason, + before=before, after=await _snapshot(session, idea), + evidence={**record, "ledger_rows_opened": opened}, + precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + return { + "promoted": True, "idea": idea.to_dict(), "ledger_rows_opened": opened, + "decision": decision.to_dict(), "precedents": consulted, + } + + +async def _existing_decision_ids(ids: list[int]) -> list[int]: + ids = [int(i) for i in ids if i] + if not ids: + return [] + async with async_session() as session: + found = set((await session.execute( + select(FamilyDecision.id).where(FamilyDecision.id.in_(ids)) + )).scalars().all()) + missing = [i for i in ids if i not in found] + if missing: + raise ValueError(f"no such family decision(s): {', '.join(map(str, missing))}") + return ids + + +async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str = "agent") -> dict: + """Demote an idea. Its history and its judged ledger rows are kept; the + rows nobody judged are closed. Undoable.""" + if not (reason or "").strip(): + raise ValueError("retiring an idea needs a reason") + if not await access.can_write_note(user_id, note_id): + raise ValueError(f"note {note_id} not found or no write access") + async with async_session() as session: + idea = await session.get(FamilyIdea, note_id) + if idea is None: + raise ValueError(f"#{note_id} is not a family idea") + if idea.status == "retired": + raise ValueError(f"#{note_id} is already retired") + before = await _snapshot(session, idea) + idea.status = "retired" + idea.updated_at = _now() + closed = await _close_unassessed(session, note_id) + decision = _log( + session, idea_id=note_id, action="retire", reason=reason, before=before, + after=await _snapshot(session, idea), evidence={"ledger_rows_closed": closed}, + precedent_ids=None, decided_via=decided_via, user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + return {"idea": idea.to_dict(), "decision": decision.to_dict()} + + +async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str = "agent") -> dict: + """Reverse an idea-level decision, restoring the state it recorded as + `before`. Only the LATEST idea-level decision on an idea can be undone — + undoing an older one would rewrite a state later decisions were built on. + The undo is itself a decision, naming the one it reverses as its + precedent, so the history keeps both. + + Undoing the proposal that created an idea retires it rather than deleting + it: deleting the idea would take its decision log with it. + """ + if not (reason or "").strip(): + raise ValueError("an undo needs a reason") + async with async_session() as session: + target = await session.get(FamilyDecision, decision_id) + if target is None: + raise ValueError(f"no such family decision: {decision_id}") + if not await access.can_write_note(user_id, target.idea_id): + raise ValueError(f"decision {decision_id} not found or no write access") + async with async_session() as session: + latest_id = (await _latest_idea_decisions(session, {target.idea_id})).get(target.idea_id) + if latest_id != target.id or not _can_undo(target): + if not _can_undo(target): + why = ("it changed nothing" if target.before == target.after + else f"a '{target.action}' decision is not undone this way") + else: + why = f"decision {latest_id} came after it — undo that one first" + raise ValueError(f"decision {decision_id} cannot be undone: {why}") + idea = await session.get(FamilyIdea, target.idea_id) + before = await _snapshot(session, idea) + prior = target.before or { + "status": "retired", "applies_when": idea.applies_when or "", + "canon_version": idea.canon_version, "platforms": before["platforms"], + } + if prior["status"] == "canon" and not (prior.get("applies_when") or "").strip(): + raise ValueError("the recorded prior state is canon with no 'applies when'") + known = await _resolve_platforms(session, prior.get("platforms") or []) + idea.status = prior["status"] + idea.applies_when = (prior.get("applies_when") or "").strip() or None + idea.canon_version = prior.get("canon_version") or idea.canon_version + idea.updated_at = _now() + await _set_platforms(session, idea.note_id, known.values()) + await session.flush() + ledger: dict = {} + if idea.status == "canon": + ledger["ledger_rows_opened"] = await _open_ledger(session, user_id, idea.note_id) + else: + ledger["ledger_rows_closed"] = await _close_unassessed(session, idea.note_id) + decision = _log( + session, idea_id=idea.note_id, action="undo", reason=reason, + before=before, after=await _snapshot(session, idea), + evidence={"undid_action": target.action, **ledger}, + precedent_ids=[target.id], decided_via=decided_via, user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + return {"idea": idea.to_dict(), "decision": decision.to_dict()} + + +# --- triggers ------------------------------------------------------------------- + +def lineage_citations(text: str | None) -> list[int]: + """The `#N`s in a text that are cited as the SOURCE of a pattern — a + lineage word ("matching", "ported from", "same shape as", …) in the same + clause just before the reference. Pure, in order, de-duplicated.""" + from scribe.services.record_refs import REF_RE + + found: list[int] = [] + for m in REF_RE.finditer(text or ""): + start = max(0, m.start() - _LINEAGE_WINDOW) + window = text[start:m.start()] + window = re.split(r"[\n.;]", window)[-1] + if _LINEAGE.search(window): + n = int(m.group(1)) + if n not in found: + found.append(n) + return found + + +async def _member_platform_ids(session, project_id: int) -> set[int]: + return set((await session.execute( + select(ProjectPlatform.platform_id).where( + ProjectPlatform.project_id == project_id, + ProjectPlatform.state.in_(MEMBER_STATES), + ) + )).scalars().all()) + + +def _evaluate_line(note_id: int) -> str: + return ( + f"Evaluate it now: get_family_idea({note_id}) shows the three criteria and " + "the nearest precedents; then promote_family_idea if all three hold, or " + "leave it a candidate (promote_family_idea records which criterion " + "failed). No one approves this — the criteria decide." + ) + + +async def citation_trigger(user_id: int, note) -> str | None: + """A record that cites ANOTHER project's record as the source of its + pattern opens an evaluation of that source as a family idea. Silent on a + citation without lineage words, and on a citation within one project.""" + if not getattr(note, "project_id", None): + return None + cited = [n for n in lineage_citations(note.body) if n != note.id] + if not cited: + return None + async with async_session() as session: + rows = (await session.execute( + select(Note.id, Note.title, Note.project_id, Project.title) + .join(Project, Project.id == Note.project_id) + .where( + Note.id.in_(cited), Note.deleted_at.is_(None), + Note.project_id.is_not(None), Note.project_id != note.project_id, + ) + )).all() + for source_id, source_title, _pid, project_title in sorted(rows, key=lambda r: cited.index(r[0])): + if not await access.can_write_note(user_id, source_id): + continue + idea, created = await propose( + user_id, source_id, trigger="citation", decided_via="system", + reason=f"#{note.id} cites it as the source of its pattern, across projects", + evidence={"cited_by": {"id": note.id, "title": note.title}}, + ) + if idea["status"] == "canon": + return ( + f"This record follows #{source_id} \"{source_title}\" ({project_title}), " + "which is already family canon. This project answers it through " + "its adoption ledger rather than by copying it." + ) + lead = "is now a family-idea candidate" if created else "is already a candidate" + return ( + f"This record cites #{source_id} \"{source_title}\" ({project_title}) as " + f"the source of its pattern — an idea carried between projects, which " + f"{lead}. {_evaluate_line(source_id)}" + ) + return None + + +async def repeat_trigger(user_id: int, note) -> str | None: + """A new record whose meaning repeats a record in ANOTHER project that + shares a platform with this one opens an evaluation of the earlier record. + Silent when the projects share no platform, below the threshold, or when + the project has no platforms yet.""" + from scribe.services.embeddings import embedding_text, semantic_search_notes + + if not getattr(note, "project_id", None) or note.is_task: + return None + async with async_session() as session: + mine = await _member_platform_ids(session, note.project_id) + if not mine: + return None + hits = await semantic_search_notes( + user_id, embedding_text(note.title, note.body), exclude_ids={note.id}, + limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=_REPEAT_KINDS, + is_task=False, demote_superseded=False, + ) + for score, other in hits: + if not other.project_id or other.project_id == note.project_id: + continue + async with async_session() as session: + shared = mine & await _member_platform_ids(session, other.project_id) + if not shared or not await access.can_write_note(user_id, other.id): + continue + idea, created = await propose( + user_id, other.id, trigger="repeat", decided_via="system", + reason=(f"#{note.id} repeats it in another project on a shared platform " + f"(similarity {score:.2f})"), + evidence={"repeated_by": {"id": note.id, "title": note.title}, + "similarity": round(float(score), 3)}, + ) + if idea["status"] == "canon": + return ( + f"This record repeats #{other.id} \"{other.title}\", which is already " + "family canon on a platform this project shares. Build from it." + ) + lead = "is now a family-idea candidate" if created else "is already a candidate" + return ( + f"This record repeats #{other.id} \"{other.title}\" from another project " + f"on a platform this one shares (similarity {score:.2f}). Two projects " + f"building the same idea is what family canon is for; #{other.id} {lead}. " + f"{_evaluate_line(other.id)}" + ) + return None + + +async def milestone_is_open(user_id: int, milestone_id: int) -> bool: + """Whether a milestone is open right now — read before a status write, so + the trigger fires on the transition into done and not on every re-save of + a closed one. Fail-open to False: a hint must never break the write.""" + from scribe.services import milestones as milestones_svc + + try: + milestone = await milestones_svc.get_milestone(user_id, milestone_id) + except Exception: + logger.warning("milestone %s status read failed", milestone_id, exc_info=True) + return False + return milestone is not None and milestone.status != "done" + + +async def milestone_trigger(user_id: int, milestone) -> str | None: + """A milestone closing in a project that is on a platform: the moment to + ask whether what it built is something every project on that platform + will face. Records nothing — a milestone is not a record an idea can hang + on — so the hint asks for the note that would carry the idea.""" + project_id = getattr(milestone, "project_id", None) + try: + if not project_id or not await access.can_read_project(user_id, project_id): + return None + async with async_session() as session: + names = (await session.execute( + select(Platform.name) + .join(ProjectPlatform, ProjectPlatform.platform_id == Platform.id) + .where(ProjectPlatform.project_id == project_id, + ProjectPlatform.state.in_(MEMBER_STATES), + Platform.deleted_at.is_(None)) + .order_by(Platform.order_index.asc()) + )).scalars().all() + except Exception: + # Fail-open: the milestone is already closed; the hint is decoration. + logger.warning("milestone trigger failed for %s", getattr(milestone, "id", None), + exc_info=True) + return None + if not names: + return None + return ( + f"This milestone closed on {', '.join(names)}. If it solved something every " + "project on those platforms will face — a problem the platform causes, or a " + "stance held across projects — the idea belongs in the family: write it up " + "as a note (when it applies, the traps, what proved it) and " + "propose_family_idea it, or promote_family_idea if it already meets the " + "three criteria. If what it built is this project's alone, nothing to do." + ) + + +async def attach_family_hint(user_id: int, data: dict, note, *, created: bool) -> None: + """Ride the trigger hints on a write's response — fail-open: a hint must + never break the write it rides on. Citation first (an explicit claim of + lineage), then, on a create, the repeat check.""" + try: + hint = await citation_trigger(user_id, note) + if hint is None and created: + hint = await repeat_trigger(user_id, note) + if hint: + data["family_hint"] = hint + except Exception: + logger.warning("family trigger failed for note %s", getattr(note, "id", None), exc_info=True) diff --git a/tests/conftest.py b/tests/conftest.py index 99e121e1..feb7026d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -215,6 +215,30 @@ def _no_lesson_rule_links(request): yield +@pytest.fixture(autouse=True) +def _no_family_triggers(request): + """Stub family canon's write-time triggers (milestone 463 step 3). + + create_note, create_task, create_snippet, the updates that change a body, + and update_milestone(status="done") now look for a pattern carried between + projects, and each look is a database read (the repeat check also embeds). + All are fail-open, so unstubbed they would cost a slow failed connect per + unit test rather than a failure. Patched at the trigger functions, beneath + `attach_family_hint`, so the attach itself — its order and its fail-open — + still runs. tests/test_family.py binds the real pure parts; the triggers + themselves are exercised against Postgres in + tests/test_integration_family_promotion.py, which this skips. + """ + if request.node.get_closest_marker("integration"): + yield + return + with patch("scribe.services.family.citation_trigger", AsyncMock(return_value=None)), \ + patch("scribe.services.family.repeat_trigger", AsyncMock(return_value=None)), \ + patch("scribe.services.family.milestone_trigger", AsyncMock(return_value=None)), \ + patch("scribe.services.family.milestone_is_open", AsyncMock(return_value=False)): + yield + + @pytest.fixture(autouse=True) def _no_moment_delivery(request): """Stub the moment lookup the mapped MCP tools attach (milestone 458). diff --git a/tests/test_family.py b/tests/test_family.py new file mode 100644 index 00000000..41b0cfda --- /dev/null +++ b/tests/test_family.py @@ -0,0 +1,163 @@ +"""The promotion engine without a database (milestone 463 step 3). + +The parts that decide — which citations claim lineage, which criteria veto — +are pure and pinned here, beside the doors' wiring: the triggers ride the +write tools, fail open, and the criteria the service enforces are the ones +the agent is told. The state machine against Postgres is in +tests/test_integration_family_promotion.py. +""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS +from scribe.mcp.tools import family as family_tools +from scribe.mcp.tools.milestones import update_milestone +from scribe.services import family as family_svc +from scribe.services.family import CRITERIA_KEYS, lineage_citations, vetoes +from tests.helpers import fake_milestone + +pytestmark = pytest.mark.usefixtures("_bind_user") + + +# --- which citations claim lineage --------------------------------------------- + +@pytest.mark.parametrize("text", [ + "Signed release lane, matching roundtable-android (task #1615).", + "Ported from #1615, with the keystore step moved first.", + "Same shape as #1615: the APK is baked into the image.", + "This mirrors #1615 for the desktop client.", + "Modelled on #1615.", +]) +def test_a_citation_with_a_lineage_word_names_its_source(text): + assert lineage_citations(text) == [1615] + + +@pytest.mark.parametrize("text", [ + "See #1615 for context.", + "#1615 is related.", + "Closes #1615.", + # The lineage word is in the PREVIOUS sentence — a different claim. + "This is matching the old flow. Unrelated: #1615.", + "Matching the old flow\nsee #1615", + "", +]) +def test_a_citation_without_lineage_is_silent(text): + assert lineage_citations(text) == [] + + +def test_lineage_citations_are_in_order_and_unique(): + text = "Based on #20; ported from #10. Same pattern as #20." + assert lineage_citations(text) == [20, 10] + + +# --- each criterion vetoes on its own ----------------------------------------------- + +GOOD = dict( + applies_when="any app that installs its own updates", + platforms=["android-app"], + criteria={ + "platform_terms": "stated as an Android distribution concern", + "platform_problem": "Play Protect and signature checks are the platform's", + "proven": "shipped in two apps", + }, + evidence=["CI run 8274 green", "#4775"], +) + + +def test_all_three_criteria_held_means_no_veto(): + assert vetoes(**GOOD) == [] + + +@pytest.mark.parametrize("key", CRITERIA_KEYS) +def test_each_criterion_left_unsupported_vetoes_on_its_own(key): + case = {**GOOD, "criteria": {**GOOD["criteria"], key: " "}} + assert vetoes(**case) == [key] + + +def test_platform_terms_needs_an_applicability_test_and_a_scope(): + assert vetoes(**{**GOOD, "applies_when": ""}) == ["platform_terms"] + assert vetoes(**{**GOOD, "platforms": []}) == ["platform_terms"] + + +def test_proven_needs_named_evidence(): + assert vetoes(**{**GOOD, "evidence": []}) == ["proven"] + assert vetoes(**{**GOOD, "evidence": [" "]}) == ["proven"] + + +def test_the_criteria_the_agent_is_told_are_the_ones_enforced(): + """Rule 119: the criteria are product text. The promote tool's docstring + must name every criterion the service checks, by its parameter name.""" + doc = family_tools.promote_family_idea.__doc__ + for key in CRITERIA_KEYS: + assert key in doc, f"promote_family_idea's docstring never names {key}" + assert len(family_svc.CRITERIA) == 3 + + +# --- the doors ---------------------------------------------------------------------- + +def test_every_family_tool_is_classified(): + reads = {"list_family_ideas", "get_family_idea", "list_family_decisions"} + writes = {"propose_family_idea", "promote_family_idea", "retire_family_idea", + "undo_family_decision"} + assert reads <= _READ_ONLY_TOOLS + assert writes <= _WRITE_TOOLS + + +def _note(**kw): + base = dict(id=7, project_id=2, title="t", body="b", is_task=False) + return SimpleNamespace(**{**base, **kw}) + + +async def test_a_citation_hint_wins_and_repeat_is_not_asked(): + data: dict = {} + repeat = AsyncMock(return_value="repeat") + with patch.object(family_svc, "citation_trigger", AsyncMock(return_value="cited")), \ + patch.object(family_svc, "repeat_trigger", repeat): + await family_svc.attach_family_hint(1, data, _note(), created=True) + assert data == {"family_hint": "cited"} + repeat.assert_not_awaited() + + +async def test_the_repeat_check_runs_only_on_a_create(): + repeat = AsyncMock(return_value="repeat") + with patch.object(family_svc, "citation_trigger", AsyncMock(return_value=None)), \ + patch.object(family_svc, "repeat_trigger", repeat): + edited: dict = {} + await family_svc.attach_family_hint(1, edited, _note(), created=False) + created: dict = {} + await family_svc.attach_family_hint(1, created, _note(), created=True) + assert edited == {} + assert created == {"family_hint": "repeat"} + + +async def test_a_failing_trigger_never_breaks_the_write(): + data: dict = {"id": 7} + with patch.object(family_svc, "citation_trigger", AsyncMock(side_effect=RuntimeError("db down"))): + await family_svc.attach_family_hint(1, data, _note(), created=True) + assert data == {"id": 7} + + +async def test_closing_a_milestone_on_a_platform_carries_the_hint(): + closed = fake_milestone(id=5, project_id=3, status="done") + with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", + AsyncMock(return_value=closed)), \ + patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=True)), \ + patch.object(family_svc, "milestone_trigger", AsyncMock(return_value="evaluate")): + out = await update_milestone(project_id=3, milestone_id=5, status="done") + assert out["family_hint"] == "evaluate" + + +async def test_re_saving_a_closed_milestone_asks_nothing(): + closed = fake_milestone(id=5, project_id=3, status="done") + trigger = AsyncMock(return_value="evaluate") + with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone", + AsyncMock(return_value=closed)), \ + patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=False)), \ + patch.object(family_svc, "milestone_trigger", trigger): + out = await update_milestone(project_id=3, milestone_id=5, status="done") + assert "family_hint" not in out + trigger.assert_not_awaited() diff --git a/tests/test_integration_family_promotion.py b/tests/test_integration_family_promotion.py new file mode 100644 index 00000000..5665dcf5 --- /dev/null +++ b/tests/test_integration_family_promotion.py @@ -0,0 +1,293 @@ +"""The promotion engine against real Postgres (milestone 463 step 3). + +What the unit lane can't show: a promotion opens the right ledger rows and +no others, a veto changes nothing but the log, an undo puts back exactly the +recorded prior state, and each trigger fires on its fixture and stays silent +on the near-miss beside it. + +Precedent search and the repeat trigger both rank by meaning, and the lane +has no embedding model. The search is stubbed to "nothing similar" for every +test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what +the trigger does WITH a hit. The ranking itself is the shared semantic +search, tested where that lives. +""" +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.family import ( + FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform, +) +from scribe.models.milestone import Milestone +from scribe.models.note import Note +from scribe.models.project import Project +from scribe.models.user import User +from scribe.services import family as family_svc +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + +OWNER = "family_promotion_owner" +OUTSIDER = "family_promotion_outsider" + +CRITERIA = { + "platform_terms": "stated for any Android app that distributes its own APK", + "platform_problem": "signature continuity is the platform's rule, not one app's", + "proven": "shipped and updated in place on a device", +} + + +async def _purge(username: str) -> None: + """SETUP ONLY, as the backup round-trip siblings do: a database call + after a `yield` in an autouse fixture orphans a pooled connection.""" + async with async_session() as s: + for user in (await s.execute(select(User).where(User.username == username))).scalars(): + for note in (await s.execute(select(Note).where(Note.user_id == user.id))).scalars(): + await s.delete(note) + for project in (await s.execute(select(Project).where(Project.user_id == user.id))).scalars(): + await s.delete(project) + await s.commit() + + +@pytest_asyncio.fixture(autouse=True) +async def _clean(): + await _purge(OWNER) + await _purge(OUTSIDER) + + +@pytest.fixture(autouse=True) +def _no_meaning(): + with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])): + yield + + +async def _platform_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 family(): + """Three projects: two Android apps and a Go service, the pattern note + in the first, and an outsider's Android app the owner cannot write.""" + android, go = await _platform_id("android-app"), await _platform_id("go") + async with async_session() as s: + owner = await ensure_user(s, OWNER) + outsider = await ensure_user(s, OUTSIDER) + a = Project(user_id=owner.id, title="android one") + b = Project(user_id=owner.id, title="android two") + c = Project(user_id=owner.id, title="go service") + theirs = Project(user_id=outsider.id, title="someone else's android app") + s.add_all([a, b, c, theirs]) + await s.flush() + s.add_all([ + ProjectPlatform(project_id=a.id, platform_id=android, state="declared"), + ProjectPlatform(project_id=b.id, platform_id=android, state="detected"), + ProjectPlatform(project_id=c.id, platform_id=go, state="declared"), + ProjectPlatform(project_id=theirs.id, platform_id=android, state="declared"), + ]) + pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane", + body="One keystore, two channels, in-place update.") + s.add(pattern) + await s.commit() + return {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id, + "c": c.id, "theirs": theirs.id, "note": pattern.id} + + +async def _promote(f, **overrides): + kw = dict( + applies_when="any Android app that ships its own APK", + platforms=["android-app"], criteria=CRITERIA, + evidence=["CI green on the release lane", "verified on a device"], + reason="built twice, proven, stated for the platform", + ) + kw.update(overrides) + return await family_svc.promote(f["owner"], f["note"], **kw) + + +async def _ledger(note_id: int) -> dict[int, str]: + async with async_session() as s: + rows = (await s.execute( + select(FamilyAdoption.project_id, FamilyAdoption.status) + .where(FamilyAdoption.idea_id == note_id) + )).all() + return dict(rows) + + +async def _idea(note_id: int) -> FamilyIdea: + async with async_session() as s: + return await s.get(FamilyIdea, note_id) + + +# --- promotion ------------------------------------------------------------------ + +async def test_promotion_opens_a_row_for_every_member_project_the_promoter_can_write(family): + out = await _promote(family) + assert out["promoted"] is True + # Declared and detected both count as membership; the Go service is not + # on the platform; the outsider's app is not the promoter's to write. + assert await _ledger(family["note"]) == {family["a"]: "unassessed", + family["b"]: "unassessed"} + idea = await _idea(family["note"]) + assert idea.status == "canon" and idea.canon_version == 1 + decision = out["decision"] + assert decision["action"] == "promote" + assert decision["after"]["platforms"] == ["android-app"] + assert decision["evidence"]["criteria"]["proven"] == CRITERIA["proven"] + assert decision["evidence"]["ledger_rows_opened"] == 2 + + +@pytest.mark.parametrize("key", list(family_svc.CRITERIA_KEYS)) +async def test_each_criterion_vetoes_on_its_own_and_the_veto_is_logged(family, key): + out = await _promote(family, criteria={**CRITERIA, key: ""}) + assert out["promoted"] is False and out["vetoed_by"] == [key] + assert (await _idea(family["note"])).status == "candidate" + assert await _ledger(family["note"]) == {} + assert out["decision"]["action"] == "propose" + assert out["decision"]["evidence"]["vetoed_by"] == [key] + + +async def test_an_unknown_platform_is_refused_before_anything_is_written(family): + with pytest.raises(ValueError, match="unknown platform"): + await _promote(family, platforms=["android-app", "no-such-platform"]) + assert await _idea(family["note"]) is None + + +async def test_someone_who_cannot_write_the_note_cannot_promote_it(family): + with pytest.raises(ValueError, match="no write access"): + await family_svc.promote( + family["outsider"], family["note"], applies_when="x", platforms=["android-app"], + criteria=CRITERIA, evidence=["e"], reason="r", + ) + + +# --- undo and retirement ------------------------------------------------------------ + +async def test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows(family): + out = await _promote(family) + async with async_session() as s: + row = (await s.execute(select(FamilyAdoption).where( + FamilyAdoption.idea_id == family["note"], + FamilyAdoption.project_id == family["a"]))).scalars().one() + row.status, row.canon_version, row.decided_via = "adopted", 1, "agent" + await s.commit() + + await family_svc.undo(family["owner"], out["decision"]["id"], reason="promoted too early") + idea = await _idea(family["note"]) + # Promoted directly, with no proposal before it: the prior state was "not + # an idea", which an undo records as retired rather than deleting the + # idea and its log with it. + assert idea.status == "retired" and idea.applies_when is None + # The unjudged row goes; the judged one stays as history. + assert await _ledger(family["note"]) == {family["a"]: "adopted"} + + again = await _promote(family) + # Re-promotion moves PAST every version this idea has held, so the kept + # answer reads as needing a recheck rather than as still agreeing. + assert again["idea"]["canon_version"] == 2 + assert await _ledger(family["note"]) == {family["a"]: "adopted", family["b"]: "unassessed"} + + +async def test_retiring_closes_unjudged_rows_and_undoing_it_reopens_them(family): + await _promote(family) + out = await family_svc.retire(family["owner"], family["note"], reason="superseded") + assert (await _idea(family["note"])).status == "retired" + assert await _ledger(family["note"]) == {} + await family_svc.undo(family["owner"], out["decision"]["id"], reason="not superseded after all") + assert (await _idea(family["note"])).status == "canon" + assert set((await _ledger(family["note"])).values()) == {"unassessed"} + + +async def test_only_the_latest_idea_decision_can_be_undone(family): + first = await _promote(family) + await family_svc.retire(family["owner"], family["note"], reason="superseded") + with pytest.raises(ValueError, match="came after it"): + await family_svc.undo(family["owner"], first["decision"]["id"], reason="r") + + +async def test_an_undo_names_what_it_reversed_as_its_precedent(family): + out = await _promote(family) + undo = await family_svc.undo(family["owner"], out["decision"]["id"], reason="r") + assert undo["decision"]["action"] == "undo" + assert undo["decision"]["precedent_ids"] == [out["decision"]["id"]] + async with async_session() as s: + actions = (await s.execute(select(FamilyDecision.action).where( + FamilyDecision.idea_id == family["note"]).order_by(FamilyDecision.id))).scalars().all() + assert actions == ["promote", "undo"] + + +# --- triggers ------------------------------------------------------------------------- + +async def _note_in(project_id: int, owner_id: int, body: str) -> Note: + async with async_session() as s: + note = Note(user_id=owner_id, project_id=project_id, title="the second build", body=body) + s.add(note) + await s.commit() + await s.refresh(note) + return note + + +async def test_a_cross_project_lineage_citation_opens_an_evaluation(family): + citing = await _note_in(family["b"], family["owner"], + f"Release lane, matching the first app (#{family['note']}).") + hint = await family_svc.citation_trigger(family["owner"], citing) + assert hint and f"#{family['note']}" in hint + idea = await _idea(family["note"]) + assert idea is not None and idea.status == "candidate" + async with async_session() as s: + d = (await s.execute(select(FamilyDecision).where( + FamilyDecision.idea_id == family["note"]))).scalars().one() + assert d.decided_via == "system" and d.evidence["trigger"] == "citation" + + +async def test_a_citation_without_lineage_or_within_one_project_is_silent(family): + pointer = await _note_in(family["b"], family["owner"], f"See #{family['note']} for context.") + same_project = await _note_in(family["a"], family["owner"], f"Matching #{family['note']}.") + assert await family_svc.citation_trigger(family["owner"], pointer) is None + assert await family_svc.citation_trigger(family["owner"], same_project) is None + assert await _idea(family["note"]) is None + + +async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family): + """The meaning match is stubbed — the lane has no model — so this pins + what the trigger does with a hit: only a hit in ANOTHER project that + shares a platform counts.""" + repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.") + async with async_session() as s: + original = await s.get(Note, family["note"]) + with patch("scribe.services.embeddings.semantic_search_notes", + AsyncMock(return_value=[(0.86, original)])): + hint = await family_svc.repeat_trigger(family["owner"], repeat) + assert hint and f"#{family['note']}" in hint + assert (await _idea(family["note"])).status == "candidate" + + +async def test_a_repeat_with_no_shared_platform_is_silent(family): + unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.") + async with async_session() as s: + original = await s.get(Note, family["note"]) + with patch("scribe.services.embeddings.semantic_search_notes", + AsyncMock(return_value=[(0.86, original)])): + assert await family_svc.repeat_trigger(family["owner"], unrelated) is None + assert await _idea(family["note"]) is None + + +async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family): + async with async_session() as s: + on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1") + bare = Project(user_id=family["owner"], title="no platforms") + s.add_all([on_platform, bare]) + await s.flush() + off_platform = Milestone(user_id=family["owner"], project_id=bare.id, title="m2") + s.add(off_platform) + await s.commit() + await s.refresh(on_platform) + await s.refresh(off_platform) + hint = await family_svc.milestone_trigger(family["owner"], on_platform) + assert hint and "Android app" in hint + assert await family_svc.milestone_trigger(family["owner"], off_platform) is None + assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True diff --git a/tests/test_routes_family.py b/tests/test_routes_family.py new file mode 100644 index 00000000..f8865933 --- /dev/null +++ b/tests/test_routes_family.py @@ -0,0 +1,29 @@ +"""Structural tests for the family blueprint (milestone 463 step 3) — every +endpoint is routed, and every write hands the service its caller, where the +note's write gate lives. What the writes do is in +tests/test_integration_family_promotion.py.""" +import inspect + + +def test_family_blueprint_routes_every_endpoint(): + from scribe.app import create_app + app = create_app() + assert "family" 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/family/ideas", "GET"), + ("/api/family/ideas/", "GET"), + ("/api/family/ideas//propose", "POST"), + ("/api/family/ideas//promote", "POST"), + ("/api/family/ideas//retire", "POST"), + ("/api/family/decisions", "GET"), + ("/api/family/decisions//undo", "POST"), + ): + assert (rule, method) in rules, f"{method} {rule} is not routed" + + +def test_every_engine_write_takes_the_caller_and_who_decided(): + from scribe.services import family as svc + for name in ("propose", "promote", "retire", "undo"): + params = inspect.signature(getattr(svc, name)).parameters + assert "user_id" in params and "decided_via" in params, name -- 2.54.0 From faa1b7230728add141eab074d0d57782c55887bf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 10:37:37 -0400 Subject: [PATCH 05/10] fix(family): undoing the decision that created an idea leaves no applicability or scope behind (#4989) When the undone decision had no recorded before-state, the synthesized prior copied the canon idea's current applies_when and platforms into a retired row. Before that decision the record was not an idea, so the restored state now has neither. Caught by test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows (run 8281). Co-Authored-By: Claude Opus 5.5 --- src/scribe/services/family.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/scribe/services/family.py b/src/scribe/services/family.py index 88f3d36a..2be70237 100644 --- a/src/scribe/services/family.py +++ b/src/scribe/services/family.py @@ -620,9 +620,12 @@ async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str raise ValueError(f"decision {decision_id} cannot be undone: {why}") idea = await session.get(FamilyIdea, target.idea_id) before = await _snapshot(session, idea) + # No recorded prior state means the decision CREATED the idea: before + # it, the record had no applicability test and no scope. It comes back + # as retired, with neither, rather than being deleted with its log. prior = target.before or { - "status": "retired", "applies_when": idea.applies_when or "", - "canon_version": idea.canon_version, "platforms": before["platforms"], + "status": "retired", "applies_when": "", + "canon_version": idea.canon_version, "platforms": [], } if prior["status"] == "canon" and not (prior.get("applies_when") or "").strip(): raise ValueError("the recorded prior state is canon with no 'applies when'") -- 2.54.0 From 201e09901b0c2aa074544960edbb74baa9dbda2d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 11:05:00 -0400 Subject: [PATCH 06/10] feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990) - services/family_adoption.py: assess one project against one canon idea by the four outcomes in order (exempt, variant, adopted, owed). Every outcome needs a reason and adopted needs evidence. The engine records the precedents itself: this idea's answers elsewhere, and this project's answers to the nearest ideas. The same answer given twice records nothing. - owed files a task in the OWING project, tagged to the System matching the idea's canonical area, naming the gap and the reference for that project's language. The task follows the answer: adopted closes it, exempt or variant cancels it, owed again reopens it. Each move is logged on the task. - the conflict order is enforced: every ground above the deciding one must say why it did not decide. The losing side is folded into the idea's note as a trap, an alternative or a condition branch, the version moves, and both rows are answered against the revision. - recheck is derived (row version != idea version). family.revise moves the version when substance changes. undo covers a project's latest answer too. - set_family_references names the reference implementations. - MCP: get/list/assess adoption, resolve_family_conflict, revise_family_idea, set_family_references. Web: GET /api/family/matrix. - UI: an adoption matrix on /family (platform filter, cell detail with reason, recheck and owed-task link) and the same matrix narrowed to one project on its Family tab. The decision log now reads project-level decisions. Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/family.ts | 84 +- .../src/components/FamilyAdoptionMatrix.vue | 328 +++++++ frontend/src/components/ProjectFamilyTab.vue | 11 + frontend/src/views/FamilyView.vue | 62 +- src/scribe/mcp/server.py | 9 +- src/scribe/mcp/tools/family.py | 216 +++- src/scribe/routes/family.py | 29 +- src/scribe/services/family.py | 189 +++- src/scribe/services/family_adoption.py | 928 ++++++++++++++++++ tests/test_family_adoption.py | 186 ++++ tests/test_integration_family_adoption.py | 389 ++++++++ tests/test_routes_family.py | 12 +- 12 files changed, 2386 insertions(+), 57 deletions(-) create mode 100644 frontend/src/components/FamilyAdoptionMatrix.vue create mode 100644 src/scribe/services/family_adoption.py create mode 100644 tests/test_family_adoption.py create mode 100644 tests/test_integration_family_adoption.py diff --git a/frontend/src/api/family.ts b/frontend/src/api/family.ts index 9af9a96b..a26b1c9e 100644 --- a/frontend/src/api/family.ts +++ b/frontend/src/api/family.ts @@ -40,15 +40,29 @@ export interface IdeaSnapshot { platforms: string[]; } +export type AdoptionStatus = "unassessed" | "adopted" | "variant" | "exempt" | "owed"; + +/** One project's answer as a decision records it. */ +export interface AdoptionSnapshot { + status: AdoptionStatus; + reason: string; + canon_version: number | null; +} + +/** + * A decision about the idea itself (project_id null) snapshots the idea; a + * project's assessment (project_id set) snapshots that project's answer. + */ export interface FamilyDecision { id: number; idea_id: number; idea_title?: string; project_id: number | null; + project_title?: string | null; action: DecisionAction; reason: string; - before: IdeaSnapshot | null; - after: IdeaSnapshot | null; + before: IdeaSnapshot | AdoptionSnapshot | null; + after: IdeaSnapshot | AdoptionSnapshot | null; evidence: Record; precedent_ids: number[]; decided_via: "agent" | "operator" | "system"; @@ -57,7 +71,17 @@ export interface FamilyDecision { undoable?: boolean; } -export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> { +/** One ground of the conflict order, in order. */ +export interface ConflictGround { + key: string; + title: string; + test: string; + fold_as: "alternative" | "trap" | "condition"; +} + +export async function listFamilyIdeas( + status = "", +): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[]; conflict_order?: ConflictGround[] }> { const q = status ? `?status=${encodeURIComponent(status)}` : ""; return apiGet(`/api/family/ideas${q}`); } @@ -76,3 +100,57 @@ export async function undoFamilyDecision(id: number, reason: string) { export async function retireFamilyIdea(noteId: number, reason: string) { return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason }); } + +export interface Outcome { + key: AdoptionStatus; + title: string; + test: string; +} + +/** One project's answer to one canon idea — a cell of the matrix. */ +export interface AdoptionCell { + project_id: number; + project_title: string; + idea_id: number; + idea_title: string; + idea_version: number; + /** The project is on one of the idea's platforms. */ + in_scope: boolean; + /** A ledger row exists. False: the idea reaches the project, nobody asked yet. */ + reached: boolean; + status: AdoptionStatus; + reason: string; + canon_version: number | null; + assessed_at: string | null; + decided_via: "agent" | "operator" | "system" | null; + /** Answered against a canon version that is not the current one. */ + needs_recheck: boolean; + owed_task: { id: number; title: string; status: string } | null; +} + +export interface MatrixIdea { + note_id: number; + title: string; + note_type: string; + is_task: boolean; + canon_version: number; + applies_when: string; + platforms: string[]; +} + +export interface AdoptionMatrix { + ideas: MatrixIdea[]; + projects: { id: number; title: string; platforms: string[] }[]; + cells: AdoptionCell[]; + outcomes: Outcome[]; +} + +export async function fetchAdoptionMatrix( + opts: { platform?: string; projectId?: number } = {}, +): Promise { + const q = new URLSearchParams(); + if (opts.platform) q.set("platform", opts.platform); + if (opts.projectId) q.set("project_id", String(opts.projectId)); + const qs = q.toString(); + return apiGet(`/api/family/matrix${qs ? `?${qs}` : ""}`); +} diff --git a/frontend/src/components/FamilyAdoptionMatrix.vue b/frontend/src/components/FamilyAdoptionMatrix.vue new file mode 100644 index 00000000..e897f778 --- /dev/null +++ b/frontend/src/components/FamilyAdoptionMatrix.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/frontend/src/components/ProjectFamilyTab.vue b/frontend/src/components/ProjectFamilyTab.vue index d9041f89..57e315ca 100644 --- a/frontend/src/components/ProjectFamilyTab.vue +++ b/frontend/src/components/ProjectFamilyTab.vue @@ -16,6 +16,10 @@ * * Each change is saved as it is made: one platform, one request, applied * whole or refused whole. + * + * Below the platforms: this project's answer to each canon idea that reaches + * it — the adoption matrix narrowed to one row, so it reads exactly as the + * family page does. */ import { computed, onMounted, ref, watch } from "vue"; import { apiErrorMessage } from "@/api/client"; @@ -24,6 +28,7 @@ import { type PlatformState, type ProjectPlatform, type SettablePlatformState, } from "@/api/platforms"; import { usePlatformsStore } from "@/stores/platforms"; +import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue"; const props = defineProps<{ projectId: number; canWrite: boolean }>(); @@ -150,6 +155,11 @@ watch(() => props.projectId, load);
+ +
+

Family ideas

+ +
@@ -195,6 +205,7 @@ watch(() => props.projectId, load); font-size: 0.85rem; flex-shrink: 0; } +.pft-answers { margin-top: 1.75rem; } @media (max-width: 640px) { .pft-row { flex-direction: column; align-items: stretch; } } diff --git a/frontend/src/views/FamilyView.vue b/frontend/src/views/FamilyView.vue index 92f856c8..67444c6e 100644 --- a/frontend/src/views/FamilyView.vue +++ b/frontend/src/views/FamilyView.vue @@ -4,17 +4,21 @@ * and the log of every decision about them. * * Nobody approves a promotion — the agent decides against the three criteria - * shown at the top, and records why. This page is where a person reads those - * decisions, and the two places they can disagree: retire an idea, or undo - * the latest decision on one. Both ask for a reason, because the reason is - * what the next decision about something similar is checked against. + * shown at the top, and records why — and nobody approves a project's answer + * either: the adoption matrix shows each project's answer to each canon idea + * as the agent gave it. This page is where a person reads those decisions, + * and the two places they can disagree: retire an idea, or undo the latest + * decision on one (or on one project's answer). Both ask for a reason, + * because the reason is what the next similar decision is checked against. */ import { computed, onMounted, ref } from "vue"; import { apiErrorMessage } from "@/api/client"; import { listFamilyDecisions, listFamilyIdeas, retireFamilyIdea, undoFamilyDecision, - type Criterion, type FamilyDecision, type FamilyIdea, type IdeaStatus, + type AdoptionSnapshot, type ConflictGround, type Criterion, type FamilyDecision, + type FamilyIdea, type IdeaSnapshot, type IdeaStatus, } from "@/api/family"; +import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue"; import { useToastStore } from "@/stores/toast"; import { fmtStamp } from "@/utils/dateFormat"; import { recordHref } from "@/utils/recordHref"; @@ -23,6 +27,8 @@ const PAGE = 50; const toast = useToastStore(); const criteria = ref([]); +const conflictOrder = ref([]); +const matrixRef = ref | null>(null); const ideas = ref([]); const decisions = ref([]); const statusFilter = ref<"" | IdeaStatus>(""); @@ -55,6 +61,7 @@ async function load() { ]); ideas.value = ideaData.ideas; criteria.value = ideaData.criteria; + conflictOrder.value = ideaData.conflict_order ?? []; decisions.value = log; moreDecisions.value = log.length === PAGE; } catch { @@ -103,7 +110,7 @@ async function confirmPending() { else await retireFamilyIdea(p.id, reason); toast.show(p.kind === "undo" ? "Decision undone" : "Idea retired"); pending.value = null; - await load(); + await Promise.all([load(), matrixRef.value?.load()]); } catch (e: unknown) { toast.show(apiErrorMessage(e, p.kind === "undo" ? "Could not undo" : "Could not retire"), "error"); } finally { @@ -144,14 +151,44 @@ function evidenceLines(d: FamilyDecision): { label: string; lines: string[] }[] if (typeof ev.trigger === "string") { out.push({ label: "Trigger", lines: [ev.trigger] }); } + const conflict = ev.conflict as { + ground: string; grounds_checked: Record; canon: string; other: string; + folded_as: string; conditions: { canon: string; other: string } | null; + } | undefined; + if (conflict) { + const title = (k: string) => conflictOrder.value.find((g) => g.key === k)?.title ?? k; + const lines = [ + `Decided by: ${title(conflict.ground)}`, + ...Object.entries(conflict.grounds_checked || {}) + .map(([k, why]) => `${title(k)} did not decide — ${why}`), + `Canon: ${conflict.canon}; the other side (${conflict.other}) was folded into the note as ${ + conflict.folded_as === "condition" ? "the branch for its condition" : `a ${conflict.folded_as}`}`, + ]; + if (conflict.conditions) { + lines.push(`When ${conflict.conditions.canon}: ${conflict.canon}`, + `When ${conflict.conditions.other}: ${conflict.other}`); + } + out.push({ label: "Conflict", lines }); + } return out; } +const ADOPTION_LABELS: Record = { + unassessed: "not assessed", adopted: "adopted", variant: "variant", + exempt: "doesn't apply", owed: "owed", +}; + function stateLine(d: FamilyDecision): string { const a = d.after; if (!a) return ""; - const where = a.platforms.length ? ` · ${a.platforms.join(", ")}` : ""; - return `${a.status} v${a.canon_version}${where}`; + if (d.project_id !== null) { + const row = a as AdoptionSnapshot; + const version = row.canon_version ? ` against v${row.canon_version}` : ""; + return `${ADOPTION_LABELS[row.status] ?? row.status}${version}`; + } + const idea = a as IdeaSnapshot; + const where = idea.platforms.length ? ` · ${idea.platforms.join(", ")}` : ""; + return `${idea.status} v${idea.canon_version}${where}`; } onMounted(load); @@ -246,6 +283,11 @@ onMounted(load); +
+

Adoption

+ +
+

Decision log

Nothing has been decided yet.

@@ -255,6 +297,10 @@ onMounted(load);
{{ ACTION_LABELS[d.action] ?? d.action }} {{ d.idea_title ?? `#${d.idea_id}` }} + #{{ d.id }} · {{ d.decided_via }} · {{ d.created_at ? fmtStamp(d.created_at) : "" }} diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 3bf22c3e..f405d829 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -163,9 +163,11 @@ _READ_ONLY_TOOLS = frozenset({ # The platform catalog and a project's answers (milestone 463). A pure # read; set_project_platforms is the write. "list_platforms", - # Family canon (milestone 463): ideas, one idea with its precedents, and - # the decision log. Reads of records the caller can read. + # Family canon (milestone 463): ideas, one idea with its precedents, the + # decision log, and the adoption ledger. Reads of records the caller can + # read. "list_family_ideas", "get_family_idea", "list_family_decisions", + "get_family_adoption", "list_family_adoptions", # 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. @@ -193,6 +195,9 @@ _WRITE_TOOLS = frozenset({ # family canon — the promotion engine "propose_family_idea", "promote_family_idea", "retire_family_idea", "undo_family_decision", + # family canon — the adoption ledger + "revise_family_idea", "assess_family_adoption", "resolve_family_conflict", + "set_family_references", "bind_repo", "unbind_repo", # snippets, processes, the shape ledger "create_snippet", "update_snippet", "delete_snippet", "verify_snippet", diff --git a/src/scribe/mcp/tools/family.py b/src/scribe/mcp/tools/family.py index 291d8a2b..8df85f05 100644 --- a/src/scribe/mcp/tools/family.py +++ b/src/scribe/mcp/tools/family.py @@ -8,6 +8,7 @@ from __future__ import annotations from scribe.mcp._context import current_user_id from scribe.services import family as family_svc +from scribe.services import family_adoption as adoption_svc async def list_family_ideas( @@ -32,8 +33,9 @@ async def list_family_ideas( async def get_family_idea(note_id: int) -> dict: """One family idea with what an evaluation needs: its state, platforms, - full decision history, ledger counts, THE THREE CRITERIA, and the - `precedents` — the decisions on the ideas nearest this one by meaning. + full decision history, ledger counts, THE THREE CRITERIA, the `ledger` + (every project's answer, with `needs_recheck`), and the `precedents` — + the decisions on the ideas nearest this one by meaning. Read the precedents before deciding, and decide consistently with them unless this idea differs in a way you can name. @@ -45,6 +47,7 @@ async def get_family_idea(note_id: int) -> dict: if idea is None: raise ValueError(f"#{note_id} is not a family idea you can read") idea["precedents"] = await family_svc.precedents(uid, note_id) + idea["ledger"] = await adoption_svc.list_adoptions(uid, idea_id=note_id) return idea @@ -146,10 +149,12 @@ async def retire_family_idea(note_id: int, reason: str) -> dict: async def undo_family_decision(decision_id: int, reason: str) -> dict: - """Reverse a family decision — a promotion, a retirement or a proposal — - restoring the state it recorded as `before`. Only the latest idea-level - decision on an idea can be undone; the undo is logged too, so the history - keeps both. + """Reverse a family decision — a promotion, a revision, a retirement, a + proposal, or one project's assessment — restoring the state it recorded + as `before`. Only the latest decision on an idea (or on one project's + answer to it) can be undone; the undo is logged too, so the history + keeps both. Undoing an owed answer closes its task; undoing back to owed + reopens it. Args: decision_id: the decision (list_family_decisions). @@ -158,26 +163,215 @@ async def undo_family_decision(decision_id: int, reason: str) -> dict: return await family_svc.undo(current_user_id(), decision_id, reason=reason) -async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict: +async def list_family_decisions( + note_id: int = 0, project_id: int = 0, limit: int = 50, offset: int = 0, +) -> dict: """The family decision log, newest first: every proposal, promotion, - veto, retirement and undo, with its reason, evidence and the precedents - it followed. `undoable` marks the decision an undo would reverse. + veto, revision, retirement, assessment and undo, with its reason, + evidence and the precedents it followed. `undoable` marks the decision an + undo would reverse — per idea, and per project's answer. Args: note_id: only this idea's decisions. 0 = all. + project_id: only this project's assessments. 0 = all. limit / offset: page through the log. """ rows = await family_svc.list_decisions( - current_user_id(), idea_id=note_id or None, + current_user_id(), idea_id=note_id or None, project_id=project_id or None, limit=max(1, min(limit, 200)), offset=max(0, offset), ) return {"decisions": rows, "limit": limit, "offset": offset} +async def revise_family_idea( + note_id: int, reason: str, applies_when: str = "", platforms: list[str] | None = None, + evidence: list[str] | None = None, +) -> dict: + """Record that a canon idea's SUBSTANCE changed — you rewrote its note's + approach, its traps or its checklist, or its applicability or platforms + moved. The canon version moves, so every project's answer given against + the old version reads `needs_recheck` until it is assessed again. + + A typo fix is not a revision. A change a project that adopted the old + version would need to act on is. + + Args: + note_id: the canon idea. + reason: what changed, in a sentence. + applies_when: a new 'when it applies'. Empty = keep the current one. + platforms: new platform slugs. Omit = keep the current ones. + evidence: what prompted the change, if anything. + """ + return await family_svc.revise( + current_user_id(), note_id, reason=reason, + applies_when=applies_when or None, platforms=platforms, evidence=evidence, + ) + + +async def get_family_adoption(project_id: int, idea_id: int) -> dict: + """Everything one assessment needs: the idea (its 'when it applies', + platforms and version), this project's current answer, THE FOUR + OUTCOMES, the `precedents` — this idea's answers in other projects and + this project's answers to the nearest ideas — and the reference + implementations beside this project's languages. Read it before + assess_family_adoption. + + Args: + project_id: the project answering. + idea_id: the canon idea. + """ + return await adoption_svc.get_adoption(current_user_id(), project_id, idea_id) + + +async def list_family_adoptions( + project_id: int = 0, idea_id: int = 0, status: str = "", needs_recheck: bool = False, + platform: str = "", +) -> dict: + """The adoption ledger: each project's answer to each canon idea that + reaches it — `unassessed`, `adopted`, `variant`, `exempt` or `owed`, with + its reason, the canon version it was given against, `needs_recheck` when + the idea has moved on since, and the owed task. + + Args: + project_id: one project's answers. 0 = all you can read. + idea_id: one idea's answers. 0 = all canon. + status: one outcome. Empty = all. + needs_recheck: only answers given against an older canon version. + platform: only ideas for this platform slug. + """ + rows = await adoption_svc.list_adoptions( + current_user_id(), project_id=project_id or None, idea_id=idea_id or None, + status=status or None, recheck_only=needs_recheck, platform=platform or None, + ) + return {"adoptions": rows} + + +async def assess_family_adoption( + project_id: int, + idea_id: int, + outcome: str, + reason: str, + evidence: list[str] | None = None, + precedent_ids: list[int] | None = None, + system_ids: list[int] | None = None, +) -> dict: + """Answer one canon family idea for one project. YOU decide; nobody + approves. Judge in this order and stop at the first that holds: + + 1. exempt — the idea's 'when it applies' is false for this project. + `reason` names the fact about the project that makes it false. + 2. variant — it applies, and the project departs for a reason that names + a FACT about itself the canon did not account for. A preference, a + taste, or "we already did it another way" is not a reason: that is + owed — or, if this project's way is better rather than different, a + conflict (resolve_family_conflict). + 3. adopted — it applies and the project does it. `evidence` names where + (a file, a commit, a task, a CI run). + 4. owed — none of the above. A task is filed in THIS project naming the + gap and the reference implementation for its language. Nothing edits + another repository; the project picks the task up itself. + + Read get_family_adoption first: answer consistently with its precedents + unless this project differs in a way you can name in `reason`. The + engine also records the precedents it found itself. + + The owed task follows the answer: adopted closes it as done, exempt or + variant cancels it, owed again reopens it. The same answer given twice + records nothing the second time. + + Args: + project_id: the project answering. + idea_id: the canon idea. + outcome: exempt | variant | adopted | owed. + reason: why — required for every outcome. + evidence: where it is done (required for adopted), or what you checked. + precedent_ids: earlier family decisions you followed, if any. + system_ids: Systems for an owed task. Omit to match the idea's own. + """ + return await adoption_svc.assess( + current_user_id(), project_id, idea_id, outcome=outcome, reason=reason, + evidence=evidence, precedent_ids=precedent_ids, system_ids=system_ids, + ) + + +async def resolve_family_conflict( + idea_id: int, + canon_project_id: int, + other_project_id: int, + ground: str, + fold: str, + reason: str, + grounds_checked: dict | None = None, + evidence: list[str] | None = None, + conditions: dict | None = None, + precedent_ids: list[int] | None = None, +) -> dict: + """Settle two projects that solve the same canon idea differently, each + for reasons it believes. YOU decide, by THE CONFLICT ORDER — the first + ground that applies wins, and for every ground above it you say in + `grounds_checked` why it did not decide: + + 1. operator_stance — one side follows a stance the operator stated (a + rule, a preference, a recorded decision). Name it in `evidence`. + 2. covers_failure — one side covers a recorded failure (an incident, an + issue, a lesson) the other does not. Name it in `evidence`. The other + side becomes owed. + 3. split_by_condition — both are right under different conditions. The + canon splits: `conditions={"canon": …, "other": …}`, and each side is + canon where its condition holds. + 4. most_recent_complete — none of the above: the side verified most + recently and covering the most wins. Name the verification. + + `canon_project_id` is the side whose approach the idea's note states + after this. If the note says something else now, rewrite it first + (update_note) — the note is the canon. + + The losing side's reasoning, `fold`, is appended to the idea's note as a + trap (ground 2), an alternative (1, 4) or the branch for its condition + (3). It is never dropped. The version moves, so every other project's + answer reads `needs_recheck`. The canon side is answered adopted; the + other owed (with a task in its project) or, in a split, adopted. + + Args: + idea_id: the canon idea. + canon_project_id: the side whose approach is canon after this. + other_project_id: the side that loses, or the split's other branch. + ground: operator_stance | covers_failure | split_by_condition | most_recent_complete. + fold: the other side's reasoning, as it should read in the note. + reason: the decision in one or two sentences. + grounds_checked: {earlier ground: why it did not decide}. + evidence: the stance, the failure or the verification. + conditions: for a split, {"canon": condition, "other": condition}. + precedent_ids: earlier family decisions you followed, if any. + """ + return await adoption_svc.resolve_conflict( + current_user_id(), idea_id, canon_project_id=canon_project_id, + other_project_id=other_project_id, ground=ground, grounds_checked=grounds_checked, + fold=fold, evidence=evidence, reason=reason, conditions=conditions, + precedent_ids=precedent_ids, + ) + + +async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict: + """Set a family idea's reference implementations — the snippets an owed + task points a project at, ideally one per language. Replaces the list. + The idea is what transfers; a reference is where to start, not code to + copy. + + Args: + note_id: the family idea. + snippet_ids: snippets implementing it. [] clears the list. + """ + refs = await adoption_svc.set_references(current_user_id(), note_id, snippet_ids or []) + return {"references": refs} + + def register(mcp) -> None: for fn in ( list_family_ideas, get_family_idea, propose_family_idea, promote_family_idea, retire_family_idea, undo_family_decision, - list_family_decisions, + list_family_decisions, revise_family_idea, get_family_adoption, + list_family_adoptions, assess_family_adoption, resolve_family_conflict, + set_family_references, ): mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/routes/family.py b/src/scribe/routes/family.py index 847750c8..acd3cafb 100644 --- a/src/scribe/routes/family.py +++ b/src/scribe/routes/family.py @@ -1,7 +1,8 @@ """Family canon routes — the web door to the promotion engine (milestone 463). -The agent decides promotions through the MCP tools; this door exists so a -person can READ every decision and its reasons, and retire an idea or undo a +The agent decides promotions and each project's answers through the MCP +tools; this door exists so a person can READ every decision and its reasons +— the ideas, the adoption matrix, the log — and retire an idea or undo a decision when they disagree. The promote and propose endpoints are here for parity with the agent's door (rule 33), and are recorded as the operator's. Every write is gated on the idea's note in the service (rule 78). @@ -13,6 +14,7 @@ from quart import Blueprint, jsonify, request from scribe.auth import get_current_user_id, login_required from scribe.routes.utils import not_found from scribe.services import family as family_svc +from scribe.services import family_adoption as adoption_svc logger = logging.getLogger(__name__) @@ -38,7 +40,10 @@ async def list_ideas_route(): platform=request.args.get("platform") or None, limit=limit, offset=offset, ) - return jsonify({"ideas": ideas, "criteria": list(family_svc.CRITERIA)}) + return jsonify({ + "ideas": ideas, "criteria": list(family_svc.CRITERIA), + "conflict_order": list(adoption_svc.CONFLICT_ORDER), + }) @family_bp.route("/ideas/", methods=["GET"]) @@ -100,13 +105,29 @@ async def retire_route(note_id: int): return jsonify(result) +@family_bp.route("/matrix", methods=["GET"]) +@login_required +async def matrix_route(): + """Projects × canon ideas — the adoption matrix. Read-only: the agent + answers through assess_family_adoption; a person reads the answers here + and undoes one from the decision log if they disagree.""" + matrix = await adoption_svc.adoption_matrix( + get_current_user_id(), + platform=request.args.get("platform") or None, + project_id=request.args.get("project_id", type=int) or None, + ) + return jsonify(matrix) + + @family_bp.route("/decisions", methods=["GET"]) @login_required async def list_decisions_route(): limit, offset = _page() idea_id = request.args.get("idea_id", type=int) + project_id = request.args.get("project_id", type=int) rows = await family_svc.list_decisions( - get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset, + get_current_user_id(), idea_id=idea_id or None, project_id=project_id or None, + limit=limit, offset=offset, ) return jsonify({"decisions": rows, "limit": limit, "offset": offset}) diff --git a/src/scribe/services/family.py b/src/scribe/services/family.py index 2be70237..946bef2f 100644 --- a/src/scribe/services/family.py +++ b/src/scribe/services/family.py @@ -232,35 +232,52 @@ async def list_ideas( async def list_decisions( - user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0, + user_id: int, *, idea_id: int | None = None, project_id: int | None = None, + limit: int = 50, offset: int = 0, ) -> list[dict]: """The decision log, newest first, over ideas the caller can read. Each - row says whether it is the one an undo would reverse.""" + row says whether it is the one an undo would reverse; a project's + assessment also carries the project's title.""" async with async_session() as session: query = ( - select(FamilyDecision, Note.title) + select(FamilyDecision, Note.title, Project.title) .join(Note, Note.id == FamilyDecision.idea_id) + .outerjoin(Project, Project.id == FamilyDecision.project_id) .where(access.readable_notes_clause(user_id)) ) if idea_id: query = query.where(FamilyDecision.idea_id == idea_id) + if project_id: + query = query.where(FamilyDecision.project_id == project_id) rows = (await session.execute( query.order_by(FamilyDecision.id.desc()).limit(limit).offset(offset) )).all() - latest = await _latest_idea_decisions(session, {d.idea_id for d, _ in rows}) + latest = await _latest_idea_decisions( + session, {d.idea_id for d, _, _ in rows if d.project_id is None}) + latest_pair = await _latest_pair_decisions( + session, {(d.idea_id, d.project_id) for d, _, _ in rows if d.project_id is not None}) out = [] - for d, title in rows: + for d, title, project_title in rows: item = _decision_dict(d, title) - item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d) + if d.project_id is None: + item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d) + else: + item["project_title"] = project_title + item["undoable"] = latest_pair.get((d.idea_id, d.project_id)) == d.id and _can_undo(d) out.append(item) return out def _can_undo(d: FamilyDecision) -> bool: - """An idea-level decision that changed something. A veto (a `propose` - whose before and after agree) changed nothing, and an undo is undone by - deciding again, not by undoing the undo.""" - return d.project_id is None and d.action in _IDEA_ACTIONS and d.before != d.after + """A decision that changed something: an idea-level one, or one + project's assessment. A veto (a `propose` whose before and after agree) + changed nothing, and an undo is undone by deciding again, not by undoing + the undo.""" + if d.before == d.after: + return False + if d.project_id is None: + return d.action in _IDEA_ACTIONS + return d.action == "assess" def _undoable_id(decisions_newest_first) -> int | None: @@ -270,6 +287,22 @@ def _undoable_id(decisions_newest_first) -> int | None: return None +async def _latest_pair_decisions(session, pairs: set[tuple[int, int]]) -> dict[tuple[int, int], int]: + """The latest decision about each (idea, project) answer — the only one of + a project's decisions on an idea that an undo may reverse.""" + if not pairs: + return {} + rows = await session.execute( + select(FamilyDecision.idea_id, FamilyDecision.project_id, func.max(FamilyDecision.id)) + .where( + FamilyDecision.idea_id.in_({i for i, _ in pairs}), + FamilyDecision.project_id.in_({p for _, p in pairs}), + ) + .group_by(FamilyDecision.idea_id, FamilyDecision.project_id) + ) + return {(i, p): d for i, p, d in rows.all() if (i, p) in pairs} + + async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]: if not idea_ids: return {} @@ -281,11 +314,10 @@ async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]: return dict(rows.all()) -async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]: - """The decisions on the ideas nearest this one by meaning — what a new - decision about it should be consistent with. Each idea contributes its - latest idea-level decision. Empty when nothing similar has been decided, - or when the embedder is unavailable.""" +async def nearest_ideas(user_id: int, note_id: int, limit: int = 5) -> list[tuple[float, Note]]: + """The family ideas nearest this record by meaning, best first, as + (score, note). Empty when there are no other ideas or the embedder is + unavailable — precedent is a help to a decision, never a gate on it.""" from scribe.services.embeddings import embedding_text, semantic_search_notes async with async_session() as session: @@ -305,7 +337,15 @@ async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]: except Exception: logger.warning("precedent search failed for idea %s", note_id, exc_info=True) return [] - ranked = [(score, n) for score, n in hits if n.id in idea_ids][:limit] + return [(score, n) for score, n in hits if n.id in idea_ids][:limit] + + +async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]: + """The decisions on the ideas nearest this one by meaning — what a new + decision about it should be consistent with. Each idea contributes its + latest idea-level decision. Empty when nothing similar has been decided, + or when the embedder is unavailable.""" + ranked = await nearest_ideas(user_id, note_id, limit) if not ranked: return [] async with async_session() as session: @@ -521,15 +561,8 @@ async def promote( # Re-promotion moves the version PAST every version this idea has ever # held — including one an undo rolled back from — so no row judged # against an earlier canon can read as agreeing with this one. - history = (await session.execute( - select(FamilyDecision.action, FamilyDecision.after) - .where(FamilyDecision.idea_id == note_id) - )).all() - if any(action == "promote" for action, _ in history): - seen = [idea.canon_version or 1] + [ - int(after.get("canon_version") or 1) for _, after in history if after - ] - idea.canon_version = max(seen) + 1 + if await _promoted_before(session, note_id): + idea.canon_version = await next_version(session, idea) idea.status = "canon" idea.applies_when = applies_when.strip() idea.updated_at = _now() @@ -550,6 +583,100 @@ async def promote( } +async def _promoted_before(session, note_id: int) -> bool: + return bool(await session.scalar( + select(func.count()).select_from(FamilyDecision) + .where(FamilyDecision.idea_id == note_id, FamilyDecision.action == "promote") + )) + + +async def next_version(session, idea: FamilyIdea) -> int: + """One past every canon version this idea has ever held — including one + an undo rolled back from — so no answer given against an earlier canon + can read as agreeing with the new one. Only idea-level snapshots carry an + idea version; an assessment's `after` holds the version it was judged + against, which is never ahead of the idea's.""" + rows = (await session.execute( + select(FamilyDecision.after).where( + FamilyDecision.idea_id == idea.note_id, FamilyDecision.project_id.is_(None), + ) + )).scalars().all() + seen = [idea.canon_version or 1] + [int(a.get("canon_version") or 1) for a in rows if a] + return max(seen) + 1 + + +async def _close_unreached(session, note_id: int) -> int: + """Delete the `unassessed` rows of projects no longer on any of the + idea's platforms — nobody judged them, and the idea no longer reaches + them. Judged rows stay, as history.""" + members = ( + select(ProjectPlatform.project_id) + .join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id) + .where(FamilyIdeaPlatform.note_id == note_id, ProjectPlatform.state.in_(MEMBER_STATES)) + ) + result = await session.execute( + delete(FamilyAdoption).where( + FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed", + FamilyAdoption.project_id.not_in(members), + ) + ) + return result.rowcount or 0 + + +async def revise( + user_id: int, note_id: int, *, reason: str, applies_when: str | None = None, + platforms: list[str] | None = None, evidence: list[str] | None = None, + decided_via: str = "agent", +) -> dict: + """Record that a canon idea's SUBSTANCE changed — its note was rewritten, + its applicability narrowed or widened, or its platforms changed. + + The version moves, so every project's answer given against the earlier + version reads as needing a recheck (derived, never a stored flag). A + project newly in scope gets an `unassessed` row; an `unassessed` row of a + project no longer in scope is closed. Undoable like any idea-level + decision. + + `applies_when` and `platforms` are left alone when None; given, they + replace the old ones and may not be empty (canon is always scoped). + """ + if not (reason or "").strip(): + raise ValueError("a revision needs a reason — what changed in the idea?") + if not await access.can_write_note(user_id, note_id): + raise ValueError(f"note {note_id} not found or no write access") + if applies_when is not None and not applies_when.strip(): + raise ValueError("canon needs an 'applies when' — leave it out to keep the current one") + if platforms is not None and not [p for p in platforms if (p or "").strip()]: + raise ValueError("canon needs at least one platform — leave it out to keep the current ones") + evidence = [str(e).strip() for e in evidence or [] if str(e).strip()] + async with async_session() as session: + idea = await session.get(FamilyIdea, note_id) + if idea is None or idea.status != "canon": + raise ValueError(f"#{note_id} is not family canon — only canon is revised") + known = await _resolve_platforms(session, platforms) if platforms is not None else None + before = await _snapshot(session, idea) + idea.canon_version = await next_version(session, idea) + if applies_when is not None: + idea.applies_when = applies_when.strip() + if known is not None: + await _set_platforms(session, note_id, known.values()) + idea.updated_at = _now() + await session.flush() + opened = await _open_ledger(session, user_id, note_id) + closed = await _close_unreached(session, note_id) + decision = _log( + session, idea_id=note_id, action="revise", reason=reason, before=before, + after=await _snapshot(session, idea), + evidence={"evidence": evidence, "ledger_rows_opened": opened, + "ledger_rows_closed": closed}, + precedent_ids=None, decided_via=decided_via, user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + return {"idea": idea.to_dict(), "decision": decision.to_dict(), + "ledger_rows_opened": opened, "ledger_rows_closed": closed} + + async def _existing_decision_ids(ids: list[int]) -> list[int]: ids = [int(i) for i in ids if i] if not ids: @@ -592,8 +719,11 @@ async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str = async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str = "agent") -> dict: - """Reverse an idea-level decision, restoring the state it recorded as - `before`. Only the LATEST idea-level decision on an idea can be undone — + """Reverse a decision, restoring the state it recorded as `before`. A + project's assessment is handed to the adoption ledger + (family_adoption.undo_assessment); the rest of this is idea-level. + + Only the LATEST idea-level decision on an idea can be undone — undoing an older one would rewrite a state later decisions were built on. The undo is itself a decision, naming the one it reverses as its precedent, so the history keeps both. @@ -607,6 +737,11 @@ async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str target = await session.get(FamilyDecision, decision_id) if target is None: raise ValueError(f"no such family decision: {decision_id}") + if target.project_id is not None: + # One project's answer: the adoption ledger restores the row. + from scribe.services import family_adoption + return await family_adoption.undo_assessment( + user_id, target, reason=reason, decided_via=decided_via) if not await access.can_write_note(user_id, target.idea_id): raise ValueError(f"decision {decision_id} not found or no write access") async with async_session() as session: diff --git a/src/scribe/services/family_adoption.py b/src/scribe/services/family_adoption.py new file mode 100644 index 00000000..72c6617f --- /dev/null +++ b/src/scribe/services/family_adoption.py @@ -0,0 +1,928 @@ +"""Family canon's adoption ledger (milestone 463 step 4). + +Promotion (services/family.py) decides that an idea is canon for some +platforms. This module decides what each project on those platforms does +about it, and keeps the answers honest as the canon moves. + +ASSESSMENT — one project, one idea, one of four outcomes, judged in order: + + exempt → adopted/variant/owed are never reached: the idea does not apply. + variant → it applies, and the project departs for a FACT about itself. + adopted → it applies and the project does it; the evidence says where. + owed → none of the above. A task is filed in the project to do it. + +No person approves an assessment. Consistency comes from precedent: every +assessment records the answers it was checked against — the same idea's +answers in other projects, and this project's answers to the nearest ideas — +found by the engine itself, so "which precedents were consulted" is a fact +about the call. Asking the same question twice with the same answer records +nothing the second time. + +OWED → TASK. An `owed` answer files a task in the OWING project — tagged to +the System matching the idea's, naming the gap and the reference +implementation for that project's language. Nothing here edits a repository: +the work is filed where its own project will pick it up. When the answer +moves on, the task follows: `adopted` closes it as done, `exempt` or +`variant` cancels it, `owed` again reopens it. Each change is logged on the +task with the reason. + +RECHECK. An answer records the canon version it was given against. When the +idea's version moves (re-promotion, revision, conflict resolution), every +answer given against another version reads `needs_recheck` — DERIVED by +comparing the two numbers, never a stored flag that could go stale. + +CONFLICT. When two projects solve the same idea differently and each thinks +its way is the right one, the conflict order decides — the first ground that +applies wins, and every ground above it must be said not to: + + 1. a stance the operator stated; + 2. the approach that covers a recorded failure (the other becomes owed); + 3. both right under different conditions (the canon splits by condition); + 4. the most recently verified and most complete. + +The losing side's reasoning is folded into the idea's note — as a trap, an +alternative, or the branch for its condition — and is never dropped. The +substance changed, so the version moves. +""" +from __future__ import annotations + +import logging +from sqlalchemy import func, select + +from scribe.models import async_session +from scribe.models.family import ( + FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference, + Platform, ProjectPlatform, +) +from scribe.models.note import Note, TaskStatus +from scribe.models.project import Project +from scribe.models.system import RecordSystem, System +from scribe.services import access +from scribe.services import family as family_svc + +logger = logging.getLogger(__name__) + +# --- the assessment and the conflict order, as product text -------------------- +# +# Judged on every install (rules 115, 119); the tool docstrings quote them. + +OUTCOMES = ( + { + "key": "exempt", + "title": "Does not apply", + "test": ( + "The idea's 'when it applies' is false for this project. The reason " + "names the fact about this project that makes it false." + ), + }, + { + "key": "variant", + "title": "Applies, and the project departs", + "test": ( + "It applies, and the project departs for a reason that names a FACT " + "about itself the canon did not account for. A preference, a taste, " + "or 'we already did it another way' is not a reason: that is owed — " + "or, if this project's way is better, a conflict to resolve." + ), + }, + { + "key": "adopted", + "title": "Applies, and the project does it", + "test": ( + "It applies and the project does it. The evidence names where — a " + "file, a commit, a task, a CI run." + ), + }, + { + "key": "owed", + "title": "Applies, and is not done yet", + "test": ( + "It applies, nothing above excuses it, and the project does not do " + "it yet. A task is filed in this project to do it." + ), + }, +) +OUTCOME_KEYS = tuple(o["key"] for o in OUTCOMES) + +CONFLICT_ORDER = ( + { + "key": "operator_stance", + "title": "A stance the operator stated", + "test": ( + "One side follows a stance the operator stated — a rule, a " + "preference, a recorded decision. It wins over anything inferred. " + "Name the stance in the evidence." + ), + "fold_as": "alternative", + }, + { + "key": "covers_failure", + "title": "Covers a recorded failure", + "test": ( + "One side covers a failure that is on record — an incident, an " + "issue, a lesson — and the other does not. Name the failure in the " + "evidence. The other side becomes owed." + ), + "fold_as": "trap", + }, + { + "key": "split_by_condition", + "title": "Both right, under different conditions", + "test": ( + "Each side is right under a condition the other does not meet. The " + "canon splits: each approach is canon where its condition holds. " + "State both conditions." + ), + "fold_as": "condition", + }, + { + "key": "most_recent_complete", + "title": "Most recently verified and most complete", + "test": ( + "None of the above decides. The side verified most recently, and " + "covering the most, wins. Name the verification in the evidence." + ), + "fold_as": "alternative", + }, +) +CONFLICT_KEYS = tuple(c["key"] for c in CONFLICT_ORDER) +_GROUND = {c["key"]: c for c in CONFLICT_ORDER} + +_OPEN_TASK = (TaskStatus.todo.value, TaskStatus.in_progress.value) + + +def _clean(items) -> list[str]: + return [str(e).strip() for e in items or [] if str(e).strip()] + + +# --- the pure checks --------------------------------------------------------------- + +def assessment_problems(*, outcome: str, reason: str, evidence: list | None) -> list[str]: + """What an assessment is missing before it can be recorded. Pure. + + Every outcome needs a reason — it is what the next assessment of a + similar idea is checked against. `adopted` also needs evidence naming + where the project does it. + """ + if outcome not in OUTCOME_KEYS: + return [f"outcome must be one of: {', '.join(OUTCOME_KEYS)}"] + problems = [] + if not (reason or "").strip(): + problems.append({ + "exempt": "exempt needs a reason naming the fact that makes 'when it applies' false here", + "variant": "variant needs a reason naming the fact about this project the canon missed", + "adopted": "adopted needs a reason", + "owed": "owed needs a reason naming the gap", + }[outcome]) + if outcome == "adopted" and not _clean(evidence): + problems.append("adopted needs evidence naming where the project does it") + return problems + + +def conflict_problems( + *, ground: str, grounds_checked: dict | None, evidence: list | None, + conditions: dict | None, fold: str, +) -> list[str]: + """What a conflict resolution is missing before it can be recorded. Pure. + + The order is enforced, not suggested: every ground ABOVE the deciding one + must carry a sentence saying why it did not decide. Grounds 1, 2 and 4 + each need evidence (the stance, the failure, the verification); a split + needs both conditions; and the losing side's reasoning is required, + because it is folded into the idea rather than dropped. + """ + if ground not in CONFLICT_KEYS: + return [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"] + checked = grounds_checked or {} + problems = [ + f"'{k}' comes before '{ground}' in the conflict order — say why it did not " + f"decide (grounds_checked['{k}'])" + for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)] + if not str(checked.get(k) or "").strip() + ] + if ground == "split_by_condition": + conds = conditions or {} + if not str(conds.get("canon") or "").strip() or not str(conds.get("other") or "").strip(): + problems.append( + "a split needs both conditions: conditions={'canon': …, 'other': …}") + elif not _clean(evidence): + problems.append({ + "operator_stance": "name the operator's stance in evidence (a rule, a preference, a decision)", + "covers_failure": "name the recorded failure in evidence (an incident, an issue, a lesson)", + "most_recent_complete": "name the verification in evidence (a CI run, a device check, a date)", + }[ground]) + if not (fold or "").strip(): + problems.append( + "the losing side's reasoning is folded into the idea, never dropped — `fold` is required") + return problems + + +def needs_recheck(row_status: str, row_version: int | None, idea_status: str, + idea_version: int) -> bool: + """An answer given against a canon version that is not the current one. + Unassessed rows have nothing to recheck; a retired idea asks nothing.""" + return ( + idea_status == "canon" and row_status != "unassessed" + and row_version is not None and row_version != idea_version + ) + + +def _row_state(row: FamilyAdoption) -> dict: + """An answer as a decision's before/after. No ids (the #3182 trap): the + owed task is the row's column, not part of the snapshot.""" + return {"status": row.status, "reason": row.reason or "", "canon_version": row.canon_version} + + +# --- reads ------------------------------------------------------------------------- + +async def _row(session, project_id: int, idea_id: int) -> FamilyAdoption | None: + return (await session.execute( + select(FamilyAdoption).where( + FamilyAdoption.project_id == project_id, FamilyAdoption.idea_id == idea_id) + )).scalars().first() + + +async def _is_member(session, project_id: int, idea_id: int) -> bool: + return bool(await session.scalar( + select(func.count()).select_from(ProjectPlatform) + .join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id) + .where( + ProjectPlatform.project_id == project_id, + FamilyIdeaPlatform.note_id == idea_id, + ProjectPlatform.state.in_(family_svc.MEMBER_STATES), + ) + )) + + +async def _references(session, idea_id: int) -> list[dict]: + rows = (await session.execute( + select(Note) + .join(FamilyIdeaReference, FamilyIdeaReference.snippet_id == Note.id) + .where(FamilyIdeaReference.idea_id == idea_id, Note.deleted_at.is_(None)) + .order_by(Note.id.asc()) + )).scalars().all() + return [ + {"id": n.id, "title": n.title, + "language": str((n.data or {}).get("language") or "").strip().lower()} + for n in rows + ] + + +async def _project_languages(session, project_id: int) -> list[str]: + """The languages of the snippets recorded in a project, most used first — + what "the reference for this project's language" is matched against.""" + lang = Note.data["language"].astext + rows = (await session.execute( + select(func.lower(lang), func.count()) + .where(Note.project_id == project_id, Note.note_type == "snippet", + Note.deleted_at.is_(None), lang.is_not(None), lang != "") + .group_by(func.lower(lang)).order_by(func.count().desc()) + )).all() + return [r[0] for r in rows] + + +async def assessment_precedents(user_id: int, project_id: int, idea_id: int, + limit: int = 5) -> list[dict]: + """What an assessment should be consistent with: the latest answer to + THIS idea in each other project the caller can read, then this project's + latest answers to the ideas nearest this one by meaning.""" + async with async_session() as session: + same = (await session.execute( + select(func.max(FamilyDecision.id)) + .where(FamilyDecision.idea_id == idea_id, FamilyDecision.action == "assess", + FamilyDecision.project_id.is_not(None), + FamilyDecision.project_id != project_id) + .group_by(FamilyDecision.project_id) + )).scalars().all() + near = await family_svc.nearest_ideas(user_id, idea_id, limit=3) + near_ids = [n.id for _, n in near] + async with async_session() as session: + mine = (await session.execute( + select(func.max(FamilyDecision.id)) + .where(FamilyDecision.idea_id.in_(near_ids), FamilyDecision.action == "assess", + FamilyDecision.project_id == project_id) + .group_by(FamilyDecision.idea_id) + )).scalars().all() if near_ids else [] + rows = (await session.execute( + select(FamilyDecision, Note.title, Project.title) + .join(Note, Note.id == FamilyDecision.idea_id) + .join(Project, Project.id == FamilyDecision.project_id) + .where(FamilyDecision.id.in_(list(same) + list(mine))) + )).all() + scores = {n.id: round(float(s), 3) for s, n in near} + out_same, out_near = [], [] + for d, idea_title, project_title in rows: + if not await access.can_read_project(user_id, d.project_id): + continue + item = d.to_dict() + item.update({"idea_title": idea_title, "project_title": project_title}) + if d.idea_id == idea_id: + item["relation"] = "same idea, another project" + out_same.append(item) + else: + item["relation"] = "nearest idea, this project" + item["similarity"] = scores.get(d.idea_id) + out_near.append(item) + out_same.sort(key=lambda x: -x["id"]) + out_near.sort(key=lambda x: -(x.get("similarity") or 0)) + return (out_same + out_near)[:limit] + + +async def adoption_matrix( + user_id: int, *, platform: str | None = None, project_id: int | None = None, +) -> dict: + """Projects × canon ideas: every answer, and every project an idea + reaches that has none yet. + + A cell exists where the project is on one of the idea's platforms, or + has an answer from before the idea's scope changed. A member project with + no row reads `unassessed` with `reached: False` — the promoter could not + write it, so nobody has been asked. Projects and ideas the caller cannot + read are left out. + """ + async with async_session() as session: + query = ( + select(FamilyIdea, Note.title, Note.note_type, Note.status) + .join(Note, Note.id == FamilyIdea.note_id) + .where(FamilyIdea.status == "canon", Note.deleted_at.is_(None), + access.readable_notes_clause(user_id)) + ) + if platform: + query = query.where(FamilyIdea.note_id.in_( + select(FamilyIdeaPlatform.note_id) + .join(Platform, Platform.id == FamilyIdeaPlatform.platform_id) + .where(Platform.slug == platform) + )) + ideas = (await session.execute(query.order_by(Note.title.asc()))).all() + idea_ids = [i.note_id for i, *_ in ideas] + idea_platforms: dict[int, dict[int, str]] = {i: {} for i in idea_ids} + for nid, pid, slug in (await session.execute( + select(FamilyIdeaPlatform.note_id, Platform.id, Platform.slug) + .join(Platform, Platform.id == FamilyIdeaPlatform.platform_id) + .where(FamilyIdeaPlatform.note_id.in_(idea_ids)) + .order_by(Platform.order_index.asc(), Platform.slug.asc()) + )).all(): + idea_platforms[nid][pid] = slug + platform_ids = {pid for m in idea_platforms.values() for pid in m} + membership: dict[int, set[int]] = {} + for pid, plat in (await session.execute( + select(ProjectPlatform.project_id, ProjectPlatform.platform_id) + .where(ProjectPlatform.platform_id.in_(platform_ids), + ProjectPlatform.state.in_(family_svc.MEMBER_STATES)) + )).all(): + membership.setdefault(pid, set()).add(plat) + rows = (await session.execute( + select(FamilyAdoption).where(FamilyAdoption.idea_id.in_(idea_ids)) + )).scalars().all() + project_ids = set(membership) | {r.project_id for r in rows} + if project_id: + project_ids &= {project_id} + projects = (await session.execute( + select(Project.id, Project.title) + .where(Project.id.in_(project_ids), Project.deleted_at.is_(None)) + .order_by(Project.title.asc()) + )).all() + task_ids = [r.owed_task_id for r in rows if r.owed_task_id] + tasks = { + t.id: t for t in (await session.execute( + select(Note).where(Note.id.in_(task_ids), Note.deleted_at.is_(None)) + )).scalars().all() + } if task_ids else {} + + readable = [(pid, title) for pid, title in projects + if await access.can_read_project(user_id, pid)] + by_pair = {(r.project_id, r.idea_id): r for r in rows} + idea_meta = {i.note_id: (i, title) for i, title, *_ in ideas} + cells = [] + for pid, ptitle in readable: + for iid in idea_ids: + idea, ititle = idea_meta[iid] + row = by_pair.get((pid, iid)) + reached = bool(membership.get(pid, set()) & set(idea_platforms[iid])) + if row is None and not reached: + continue + cell = { + "project_id": pid, "project_title": ptitle, + "idea_id": iid, "idea_title": ititle, + "idea_version": idea.canon_version, + "in_scope": reached, + "reached": row is not None, + "status": row.status if row else "unassessed", + "reason": (row.reason or "") if row else "", + "canon_version": row.canon_version if row else None, + "assessed_at": row.to_dict()["assessed_at"] if row else None, + "decided_via": row.decided_via if row else None, + "needs_recheck": bool(row) and needs_recheck( + row.status, row.canon_version, idea.status, idea.canon_version), + "owed_task": None, + } + task = tasks.get(row.owed_task_id) if row and row.owed_task_id else None + if task is not None: + cell["owed_task"] = {"id": task.id, "title": task.title, "status": task.status} + cells.append(cell) + + seen_projects = {c["project_id"] for c in cells} + return { + "ideas": [ + { + "note_id": i.note_id, "title": title, "note_type": note_type or "note", + "is_task": note_status is not None, "canon_version": i.canon_version, + "applies_when": i.applies_when or "", + "platforms": list(idea_platforms[i.note_id].values()), + } + for i, title, note_type, note_status in ideas + ], + "projects": [ + {"id": pid, "title": title, + "platforms": sorted({s for iid in idea_ids + for p, s in idea_platforms[iid].items() + if p in membership.get(pid, set())})} + for pid, title in readable if pid in seen_projects + ], + "cells": cells, + "outcomes": list(OUTCOMES), + } + + +async def list_adoptions( + user_id: int, *, project_id: int | None = None, idea_id: int | None = None, + status: str | None = None, recheck_only: bool = False, platform: str | None = None, +) -> list[dict]: + """The ledger as rows — the matrix's cells, filtered.""" + matrix = await adoption_matrix(user_id, platform=platform, project_id=project_id) + return [ + c for c in matrix["cells"] + if (not idea_id or c["idea_id"] == idea_id) + and (not status or c["status"] == status) + and (not recheck_only or c["needs_recheck"]) + ] + + +async def get_adoption(user_id: int, project_id: int, idea_id: int) -> dict: + """Everything one assessment reads: the idea, this project's current + answer, the four outcomes, the precedents, and the reference + implementations with this project's languages.""" + if not await access.can_read_project(user_id, project_id): + raise ValueError(f"project {project_id} not found") + idea = await family_svc.get_idea(user_id, idea_id) + if idea is None: + raise ValueError(f"#{idea_id} is not a family idea you can read") + rows = await list_adoptions(user_id, project_id=project_id, idea_id=idea_id) + async with async_session() as session: + refs = await _references(session, idea_id) + langs = await _project_languages(session, project_id) + idea.pop("decisions", None) + return { + "idea": idea, + "adoption": rows[0] if rows else None, + "outcomes": list(OUTCOMES), + "precedents": await assessment_precedents(user_id, project_id, idea_id), + "references": refs, + "project_languages": langs, + } + + +# --- the owed task ----------------------------------------------------------------- + +def _reference_line(refs: list[dict], langs: list[str], idea_id: int) -> str: + def fmt(r): + return f"#{r['id']} “{r['title']}”" + (f" ({r['language']})" if r["language"] else "") + + if not refs: + return (f"No reference implementation is recorded yet — build from the idea's " + f"note, #{idea_id}.") + mine = [r for r in refs if r["language"] and r["language"] in langs] + if mine: + return "; ".join(fmt(r) for r in mine) + said = ", ".join(langs) if langs else "not yet known" + return (f"None is recorded in this project's language ({said}). The idea is what " + f"transfers; the ones that exist: {'; '.join(fmt(r) for r in refs)}.") + + +async def _matching_systems(session, project_id: int, note_ids: list[int]) -> list[int]: + """The project's Systems matching the idea's: the same canonical area as + one the idea (or a reference) is filed under, or that very System when + the idea was written in this project.""" + tagged = (await session.execute( + select(System.id, System.project_id, System.canonical_id) + .join(RecordSystem, RecordSystem.system_id == System.id) + .where(RecordSystem.note_id.in_(note_ids), System.deleted_at.is_(None)) + )).all() + direct = [sid for sid, pid, _ in tagged if pid == project_id] + canonical = {cid for _, _, cid in tagged if cid is not None} + mapped = (await session.execute( + select(System.id).where( + System.project_id == project_id, System.canonical_id.in_(canonical), + System.deleted_at.is_(None)) + .order_by(System.order_index.asc(), System.id.asc()) + )).scalars().all() if canonical else [] + return list(dict.fromkeys(direct + list(mapped))) + + +async def _file_owed_task(user_id: int, row_id: int, reason: str, + system_ids: list[int] | None) -> Note: + from scribe.services import notes as notes_svc + from scribe.services import systems as systems_svc + + async with async_session() as session: + row = await session.get(FamilyAdoption, row_id) + idea = await session.get(FamilyIdea, row.idea_id) + note = await session.get(Note, row.idea_id) + refs = await _references(session, row.idea_id) + langs = await _project_languages(session, row.project_id) + platforms = await family_svc._platform_slugs(session, row.idea_id) + systems = system_ids or await _matching_systems( + session, row.project_id, [row.idea_id] + [r["id"] for r in refs]) + project_id, idea_id, version = row.project_id, row.idea_id, idea.canon_version + body = ( + f"Family idea #{idea_id} “{note.title}” is canon for " + f"{', '.join(platforms) or 'its platforms'}, applies to this project, and is " + f"not done here yet.\n\n" + f"**When it applies:** {idea.applies_when or '—'}\n\n" + f"**The gap:** {reason.strip()}\n\n" + f"**Start from:** {_reference_line(refs, langs, idea_id)}\n\n" + "Build the idea in this project's own language and conventions: what the " + "family shares is the idea, its traps and its checklist, not the code. When " + "it is done, assess it again as `adopted` (assess_family_adoption) with where " + "it lives as evidence; that closes this task. If it turns out not to apply, " + "or this project has a reason to depart that is a fact about itself, assess " + "it `exempt` or `variant` instead.\n\n" + f"_Filed by the family adoption ledger against canon version {version}._" + ) + task = await notes_svc.create_note( + user_id, title=f"Adopt the family idea “{note.title}”"[:500], body=body, + project_id=project_id, status=TaskStatus.todo.value, task_kind="work", + ) + if systems: + await systems_svc.set_record_systems(user_id, task.id, systems) + return task + + +async def _set_task_status(user_id: int, task: Note, status: str, log: str) -> None: + from scribe.services import notes as notes_svc + from scribe.services import task_logs + + # The writer is checked; the write goes through the owner's door, which + # is the one that keeps versions, claims and the embedding in step. + await notes_svc.update_note(task.user_id, task.id, status=status) + await task_logs.create_log(user_id, task.id, log) + + +async def _sync_owed_task(user_id: int, row_id: int, *, reason: str, + system_ids: list[int] | None = None) -> dict | None: + """Make the owed task agree with the answer. Idempotent: an `owed` row + with an open task, or a settled row with a closed one, is left alone. + A task the caller cannot write is left alone too, and said so.""" + async with async_session() as session: + row = await session.get(FamilyAdoption, row_id) + task = await session.get(Note, row.owed_task_id) if row.owed_task_id else None + if task is not None and task.deleted_at is not None: + task = None + status, idea_id = row.status, row.idea_id + + def ref(t: Note, s: str, action: str | None = None) -> dict: + out = {"id": t.id, "title": t.title, "status": s} + if action: + out["action"] = action + return out + + if status == "owed": + if task is not None and task.status in _OPEN_TASK: + return ref(task, task.status) + if task is not None and await access.can_write_note(user_id, task.id): + await _set_task_status( + user_id, task, TaskStatus.todo.value, + f"Reopened: family idea #{idea_id} was assessed owed again — {reason}") + return ref(task, TaskStatus.todo.value, "reopened") + task = await _file_owed_task(user_id, row_id, reason, system_ids) + async with async_session() as session: + row = await session.get(FamilyAdoption, row_id) + row.owed_task_id = task.id + await session.commit() + return ref(task, task.status, "filed") + + if task is None: + return None + if task.status not in _OPEN_TASK: + return ref(task, task.status) + if not await access.can_write_note(user_id, task.id): + return ref(task, task.status, "left open — no write access to the task") + new = TaskStatus.done.value if status == "adopted" else TaskStatus.cancelled.value + if status == "unassessed": + line = f"Family idea #{idea_id}'s owed answer was undone — {reason}" + else: + line = f"Family idea #{idea_id} was assessed {status} in this project — {reason}" + await _set_task_status(user_id, task, new, line) + return ref(task, new, "closed") + + +# --- writes ------------------------------------------------------------------------- + +async def _row_for_write(session, project_id: int, idea_id: int) -> FamilyAdoption: + row = await _row(session, project_id, idea_id) + if row is not None: + return row + if not await _is_member(session, project_id, idea_id): + raise ValueError( + f"project {project_id} is not on any of #{idea_id}'s platforms, so the idea " + "does not reach it — answer the project's platforms first if it should") + row = FamilyAdoption(project_id=project_id, idea_id=idea_id, status="unassessed") + session.add(row) + await session.flush() + return row + + +def _answer(row: FamilyAdoption, *, status: str, reason: str, version: int, + decided_via: str) -> None: + row.status = status + row.reason = reason.strip() or None + row.canon_version = version + row.assessed_at = family_svc._now() + row.decided_via = decided_via + row.updated_at = family_svc._now() + + +async def assess( + user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str, + evidence: list[str] | None = None, precedent_ids: list[int] | None = None, + system_ids: list[int] | None = None, decided_via: str = "agent", +) -> dict: + """Record one project's answer to one canon idea. + + Raises ValueError before writing anything when the answer is malformed + (assessment_problems), the caller cannot write the project, or the idea + is not canon or does not reach the project. + + The same answer given again — same outcome, reason and canon version — + records nothing (`changed: False`); it still makes sure an owed answer + has an open task. The response carries the precedents consulted, and + `owed_task` when there is one. + """ + evidence = _clean(evidence) + problems = assessment_problems(outcome=outcome, reason=reason, evidence=evidence) + if problems: + raise ValueError("; ".join(problems)) + if not await access.can_write_project(user_id, project_id): + raise ValueError(f"project {project_id} not found or no write access") + if not await access.can_read_note(user_id, idea_id): + raise ValueError(f"#{idea_id} is not a family idea you can read") + consulted = await assessment_precedents(user_id, project_id, idea_id) + named = await family_svc._existing_decision_ids(precedent_ids or []) + precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted])) + + async with async_session() as session: + idea = await session.get(FamilyIdea, idea_id) + if idea is None or idea.status != "canon": + raise ValueError(f"#{idea_id} is not family canon — only canon is assessed") + row = await _row_for_write(session, project_id, idea_id) + before = _row_state(row) + after = {"status": outcome, "reason": reason.strip(), "canon_version": idea.canon_version} + changed = before != after + decision = None + if changed: + _answer(row, status=outcome, reason=reason, version=idea.canon_version, + decided_via=decided_via) + decision = family_svc._log( + session, idea_id=idea_id, project_id=project_id, action="assess", + reason=reason, before=before, after=_row_state(row), + evidence={"evidence": evidence}, precedent_ids=precedent_list, + decided_via=decided_via, user_id=user_id, + ) + await session.commit() + if decision is not None: + await session.refresh(decision) + row_id = row.id + task = await _sync_owed_task(user_id, row_id, reason=reason, system_ids=system_ids) + rows = await list_adoptions(user_id, project_id=project_id, idea_id=idea_id) + return { + "changed": changed, + "adoption": rows[0] if rows else None, + "decision": decision.to_dict() if decision is not None else None, + "precedents": consulted, + "owed_task": task, + } + + +async def undo_assessment(user_id: int, target: FamilyDecision, *, reason: str, + decided_via: str = "agent") -> dict: + """Reverse one project's answer, restoring the row it recorded as + `before`. Only the latest decision on that (idea, project) answer can be + undone. The owed task follows the restored answer.""" + if not await access.can_write_project(user_id, target.project_id): + raise ValueError(f"decision {target.id} not found or no write access") + async with async_session() as session: + latest = (await family_svc._latest_pair_decisions( + session, {(target.idea_id, target.project_id)})).get((target.idea_id, target.project_id)) + if latest != target.id: + raise ValueError( + f"decision {target.id} cannot be undone: decision {latest} came after it — " + "undo that one first") + if not family_svc._can_undo(target): + raise ValueError(f"decision {target.id} cannot be undone: it changed nothing") + row = await _row(session, target.project_id, target.idea_id) + if row is None: + raise ValueError(f"decision {target.id} cannot be undone: the answer it changed is gone") + before = _row_state(row) + prior = target.before or {"status": "unassessed", "reason": "", "canon_version": None} + row.status = prior["status"] + row.reason = (prior.get("reason") or "").strip() or None + row.canon_version = prior.get("canon_version") + if row.status == "unassessed": + row.assessed_at = None + row.decided_via = None + else: + row.assessed_at = family_svc._now() + row.decided_via = decided_via + row.updated_at = family_svc._now() + decision = family_svc._log( + session, idea_id=target.idea_id, project_id=target.project_id, action="undo", + reason=reason, before=before, after=_row_state(row), + evidence={"undid_action": target.action}, precedent_ids=[target.id], + decided_via=decided_via, user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + row_id = row.id + task = await _sync_owed_task(user_id, row_id, reason=f"undo of decision {target.id}: {reason}") + async with async_session() as session: + idea = await session.get(FamilyIdea, target.idea_id) + row = await session.get(FamilyAdoption, row_id) + adoption = row.to_dict() + return {"idea": idea.to_dict(), "adoption": adoption, + "decision": decision.to_dict(), "owed_task": task} + + +async def set_references(user_id: int, idea_id: int, snippet_ids: list[int]) -> list[dict]: + """Replace an idea's reference implementations — the snippets an owed + task points a project at, one per language. Each must be a snippet the + caller can read; the idea must be theirs to write.""" + if not await access.can_write_note(user_id, idea_id): + raise ValueError(f"note {idea_id} not found or no write access") + wanted = list(dict.fromkeys(int(i) for i in snippet_ids or [] if i)) + async with async_session() as session: + if await session.get(FamilyIdea, idea_id) is None: + raise ValueError(f"#{idea_id} is not a family idea") + found = {n.id: n for n in (await session.execute( + select(Note).where(Note.id.in_(wanted), Note.deleted_at.is_(None)) + )).scalars().all()} if wanted else {} + bad = [i for i in wanted + if i not in found or (found[i].note_type or "") != "snippet"] + if bad: + raise ValueError(f"not a snippet: {', '.join(map(str, bad))}") + for i in wanted: + if not await access.can_read_note(user_id, i): + raise ValueError(f"not a snippet: {i}") + current = set((await session.execute( + select(FamilyIdeaReference.snippet_id).where(FamilyIdeaReference.idea_id == idea_id) + )).scalars().all()) + for sid in current - set(wanted): + await session.delete(await session.get(FamilyIdeaReference, (idea_id, sid))) + for sid in wanted: + if sid not in current: + session.add(FamilyIdeaReference(idea_id=idea_id, snippet_id=sid)) + await session.commit() + return await _references(session, idea_id) + + +def _fold_section(*, ground: str, fold: str, conditions: dict | None, + canon_title: str, other_title: str, when: str) -> str: + g = _GROUND[ground] + if g["fold_as"] == "condition": + conds = conditions or {} + return ( + f"### When {conds['other'].strip()} — {other_title}'s approach\n\n" + f"{fold.strip()}\n\n" + f"When {conds['canon'].strip()}, {canon_title}'s approach above is the canon. " + f"_(Family conflict, {when}: {g['title'].lower()}.)_" + ) + heading = "Trap" if g["fold_as"] == "trap" else "Alternative" + return ( + f"### {heading} — {other_title}'s approach\n\n{fold.strip()}\n\n" + f"_(Family conflict, {when}: decided by {g['title'].lower()}; " + f"{canon_title}'s approach is the canon.)_" + ) + + +async def resolve_conflict( + user_id: int, idea_id: int, *, canon_project_id: int, other_project_id: int, + ground: str, grounds_checked: dict | None, fold: str, evidence: list[str] | None, + reason: str, conditions: dict | None = None, precedent_ids: list[int] | None = None, + decided_via: str = "agent", +) -> dict: + """Settle two projects that solve the same canon idea differently, by + the conflict order. + + `canon_project_id` is the side whose approach the idea's note states + after this — if that is not what the note says now, rewrite the note + first (update_note); the note is the canon. `other_project_id` is the + side that loses, or in a split, the side whose condition is the branch. + + Effects, in order: the losing side's reasoning (`fold`) is appended to + the idea's note — as a trap, an alternative, or the branch for its + condition; the version moves (a `revise` decision carrying the ground and + the grounds checked above it); the canon side is answered `adopted`, and + the other side `owed` (with a task filed) or, in a split, `adopted` under + its condition. Each answer is its own `assess` decision, naming the + revision as its precedent. + """ + from scribe.services import notes as notes_svc + + evidence = _clean(evidence) + problems = conflict_problems(ground=ground, grounds_checked=grounds_checked, + evidence=evidence, conditions=conditions, fold=fold) + if canon_project_id == other_project_id: + problems.append("a conflict is between two different projects") + if not (reason or "").strip(): + problems.append("a resolution needs a reason") + if problems: + raise ValueError("; ".join(problems)) + if not await access.can_write_note(user_id, idea_id): + raise ValueError(f"note {idea_id} not found or no write access") + for pid in (canon_project_id, other_project_id): + if not await access.can_write_project(user_id, pid): + raise ValueError(f"project {pid} not found or no write access") + named = await family_svc._existing_decision_ids(precedent_ids or []) + consulted = await family_svc.precedents(user_id, idea_id) + precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted])) + + async with async_session() as session: + idea = await session.get(FamilyIdea, idea_id) + if idea is None or idea.status != "canon": + raise ValueError(f"#{idea_id} is not family canon — only canon has conflicts") + for pid in (canon_project_id, other_project_id): + if await _row(session, pid, idea_id) is None and not await _is_member(session, pid, idea_id): + raise ValueError(f"#{idea_id} does not reach project {pid}") + titles = dict((await session.execute( + select(Project.id, Project.title) + .where(Project.id.in_([canon_project_id, other_project_id])) + )).all()) + note = await session.get(Note, idea_id) + owner, body = note.user_id, note.body or "" + + # The losing side's reasoning goes into the canon FIRST: whatever fails + # after this, the reasoning is not dropped. + section = _fold_section( + ground=ground, fold=fold, conditions=conditions, + canon_title=titles[canon_project_id], other_title=titles[other_project_id], + when=family_svc._now().date().isoformat(), + ) + await notes_svc.update_note(owner, idea_id, body=f"{body.rstrip()}\n\n{section}\n") + + split = ground == "split_by_condition" + other_outcome = "adopted" if split else "owed" + g = _GROUND[ground] + async with async_session() as session: + idea = await session.get(FamilyIdea, idea_id) + before = await family_svc._snapshot(session, idea) + idea.canon_version = await family_svc.next_version(session, idea) + idea.updated_at = family_svc._now() + await session.flush() + revision = family_svc._log( + session, idea_id=idea_id, action="revise", reason=reason, before=before, + after=await family_svc._snapshot(session, idea), + evidence={ + "conflict": { + "ground": ground, + "grounds_checked": {k: str((grounds_checked or {}).get(k) or "").strip() + for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]}, + "canon": titles[canon_project_id], + "other": titles[other_project_id], + "folded_as": g["fold_as"], + "conditions": conditions if split else None, + }, + "evidence": evidence, + }, + precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id, + ) + await session.flush() + row_ids = {} + answers = ( + (canon_project_id, "adopted", + f"Canon after a family conflict ({g['title'].lower()}): {reason.strip()}"), + (other_project_id, other_outcome, + (f"Canon where {conditions['other'].strip()} (split by condition): {reason.strip()}" + if split else + f"Lost a family conflict ({g['title'].lower()}) — adopt the canon: {reason.strip()}")), + ) + for pid, outcome, why in answers: + row = await _row_for_write(session, pid, idea_id) + row_before = _row_state(row) + _answer(row, status=outcome, reason=why, version=idea.canon_version, + decided_via=decided_via) + family_svc._log( + session, idea_id=idea_id, project_id=pid, action="assess", reason=why, + before=row_before, after=_row_state(row), evidence={"evidence": evidence}, + precedent_ids=[revision.id], decided_via=decided_via, user_id=user_id, + ) + row_ids[pid] = row.id + await session.commit() + await session.refresh(revision) + task = await _sync_owed_task(user_id, row_ids[other_project_id], reason=answers[1][2]) + await _sync_owed_task(user_id, row_ids[canon_project_id], reason=answers[0][2]) + return { + "idea": idea.to_dict(), + "decision": revision.to_dict(), + "folded_as": g["fold_as"], + "adoptions": await list_adoptions(user_id, idea_id=idea_id), + "owed_task": task, + } diff --git a/tests/test_family_adoption.py b/tests/test_family_adoption.py new file mode 100644 index 00000000..63a59636 --- /dev/null +++ b/tests/test_family_adoption.py @@ -0,0 +1,186 @@ +"""The adoption ledger without a database (milestone 463 step 4). + +The parts that decide are pure and pinned here: what an assessment needs +before it can be recorded, what each ground of the conflict order needs — +including that every ground ABOVE the deciding one was said not to apply — +when an answer needs a recheck, how a losing side is folded into the note, +and which reference an owed task points at. Beside them: the outcomes and +the order the agent is told are the ones enforced. The state machine against +Postgres is in tests/test_integration_family_adoption.py. +""" +from __future__ import annotations + +import pytest + +from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS +from scribe.services.family_adoption import ( + CONFLICT_KEYS, CONFLICT_ORDER, OUTCOME_KEYS, _fold_section, _reference_line, + assessment_problems, conflict_problems, needs_recheck, +) +from tests.helpers import tool_doc + + +# --- what an assessment needs ------------------------------------------------------ + +@pytest.mark.parametrize("outcome", OUTCOME_KEYS) +def test_every_outcome_needs_a_reason(outcome): + problems = assessment_problems(outcome=outcome, reason=" ", evidence=["src/x.py"]) + assert len(problems) == 1 and outcome in problems[0] + + +def test_adopted_needs_evidence_naming_where(): + assert assessment_problems(outcome="adopted", reason="done", evidence=[]) == [ + "adopted needs evidence naming where the project does it"] + assert assessment_problems(outcome="adopted", reason="done", evidence=[" "]) != [] + assert assessment_problems(outcome="adopted", reason="done", evidence=["ci run 12"]) == [] + + +@pytest.mark.parametrize("outcome", ("exempt", "variant", "owed")) +def test_the_other_outcomes_need_no_evidence(outcome): + assert assessment_problems(outcome=outcome, reason="a fact", evidence=None) == [] + + +def test_an_unknown_outcome_is_refused_by_name(): + problems = assessment_problems(outcome="ignored", reason="x", evidence=[]) + assert problems and all(k in problems[0] for k in OUTCOME_KEYS) + + +# --- the conflict order, branch by branch ---------------------------------------- + +def _checked(upto: str) -> dict: + return {k: f"{k} did not decide" for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(upto)]} + + +GOOD = { + "operator_stance": dict(evidence=["rule: one keystore per app"]), + "covers_failure": dict(evidence=["incident #12: update refused, signature mismatch"]), + "split_by_condition": dict(conditions={"canon": "the app self-updates", + "other": "a store installs it"}), + "most_recent_complete": dict(evidence=["verified on a device 2026-10-01"]), +} + + +@pytest.mark.parametrize("ground", CONFLICT_KEYS) +def test_each_ground_holds_when_its_needs_are_met(ground): + kw = dict(evidence=None, conditions=None, **{**GOOD[ground]}) + assert conflict_problems(ground=ground, grounds_checked=_checked(ground), + fold="their reasoning", **kw) == [] + + +@pytest.mark.parametrize("ground", CONFLICT_KEYS[1:]) +def test_a_ground_is_refused_while_any_ground_above_it_is_unanswered(ground): + above = CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)] + for skipped in above: + checked = {k: v for k, v in _checked(ground).items() if k != skipped} + problems = conflict_problems(ground=ground, grounds_checked=checked, + fold="x", **{"evidence": None, "conditions": None, + **GOOD[ground]}) + assert len(problems) == 1 and f"'{skipped}'" in problems[0] + + +def test_the_first_ground_needs_nothing_checked_above_it(): + assert conflict_problems(ground="operator_stance", grounds_checked=None, fold="x", + evidence=["rule 4"], conditions=None) == [] + + +@pytest.mark.parametrize("ground", ("operator_stance", "covers_failure", "most_recent_complete")) +def test_grounds_one_two_and_four_need_their_evidence(ground): + problems = conflict_problems(ground=ground, grounds_checked=_checked(ground), fold="x", + evidence=[" "], conditions=None) + assert len(problems) == 1 and "evidence" in problems[0] + + +def test_a_split_needs_both_conditions(): + for conds in (None, {"canon": "self-updates"}, {"canon": "", "other": "store"}): + problems = conflict_problems(ground="split_by_condition", + grounds_checked=_checked("split_by_condition"), + fold="x", evidence=None, conditions=conds) + assert len(problems) == 1 and "conditions" in problems[0] + + +def test_the_losing_reasoning_is_required(): + problems = conflict_problems(ground="operator_stance", grounds_checked=None, fold=" ", + evidence=["rule 4"], conditions=None) + assert len(problems) == 1 and "never dropped" in problems[0] + + +def test_an_unknown_ground_names_the_order(): + problems = conflict_problems(ground="seniority", grounds_checked=None, fold="x", + evidence=[], conditions=None) + assert problems == [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"] + + +# --- recheck ------------------------------------------------------------------------- + +@pytest.mark.parametrize("row_status,row_version,idea_status,idea_version,expected", [ + ("adopted", 1, "canon", 2, True), + ("owed", 1, "canon", 2, True), + ("adopted", 2, "canon", 2, False), + # An undone revision leaves answers AHEAD of the idea: still not agreeing. + ("adopted", 3, "canon", 2, True), + ("unassessed", None, "canon", 2, False), + ("adopted", 1, "retired", 2, False), +]) +def test_needs_recheck(row_status, row_version, idea_status, idea_version, expected): + assert needs_recheck(row_status, row_version, idea_status, idea_version) is expected + + +# --- folding the losing side into the note ------------------------------------------ + +@pytest.mark.parametrize("ground,heading", [ + ("operator_stance", "### Alternative — two's approach"), + ("covers_failure", "### Trap — two's approach"), + ("most_recent_complete", "### Alternative — two's approach"), + ("split_by_condition", "### When a store installs it — two's approach"), +]) +def test_the_losing_side_is_folded_as_its_ground_says(ground, heading): + text = _fold_section(ground=ground, fold="Their reasoning.", + conditions=GOOD["split_by_condition"]["conditions"], + canon_title="one", other_title="two", when="2026-10-06") + assert text.startswith(heading) + assert "Their reasoning." in text and "one's approach" in text + + +# --- the reference an owed task points at ----------------------------------------- + +REFS = [ + {"id": 10, "title": "Update installer", "language": "kotlin"}, + {"id": 11, "title": "Update installer", "language": "dart"}, +] + + +def test_the_reference_in_the_projects_language_is_named(): + line = _reference_line(REFS, ["dart"], idea_id=5) + assert "#11" in line and "#10" not in line + + +def test_no_reference_in_the_language_names_the_ones_that_exist(): + line = _reference_line(REFS, ["python"], idea_id=5) + assert "python" in line and "#10" in line and "#11" in line + + +def test_no_reference_at_all_points_at_the_idea(): + assert "#5" in _reference_line([], ["python"], idea_id=5) + + +# --- the product text and the doors --------------------------------------------------- + +def test_the_outcomes_the_agent_is_told_are_the_ones_enforced(): + """Rule 119: the outcomes and the order are product text. The tool + docstrings must name every outcome and every ground the service checks, + in the service's order.""" + assess_doc = tool_doc("scribe.mcp.tools.family", "assess_family_adoption") + for n, key in enumerate(OUTCOME_KEYS, start=1): + assert f"{n}. {key} —" in assess_doc, f"outcome {n} is not taught as {key}" + resolve_doc = tool_doc("scribe.mcp.tools.family", "resolve_family_conflict") + for n, key in enumerate(CONFLICT_KEYS, start=1): + assert f"{n}. {key} —" in resolve_doc, f"ground {n} is not taught as {key}" + assert [c["fold_as"] for c in CONFLICT_ORDER] == ["alternative", "trap", "condition", "alternative"] + + +def test_every_adoption_tool_is_classified(): + reads = {"get_family_adoption", "list_family_adoptions"} + writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea", + "set_family_references"} + assert reads <= _READ_ONLY_TOOLS + assert writes <= _WRITE_TOOLS diff --git a/tests/test_integration_family_adoption.py b/tests/test_integration_family_adoption.py new file mode 100644 index 00000000..bb75a100 --- /dev/null +++ b/tests/test_integration_family_adoption.py @@ -0,0 +1,389 @@ +"""The adoption ledger against real Postgres (milestone 463 step 4). + +What the unit lane can't show: an answer moves the row and logs exactly one +decision; an owed answer files its task in the OWING project, tagged to the +System matching the idea's, and the task follows the answer from there; a +version bump leaves every older answer reading as needing a recheck; each +branch of the conflict order folds the losing side into the note and settles +both rows; and the same assessment given twice records nothing the second +time. + +Precedent search ranks by meaning and the lane has no embedding model, so +the search is stubbed to "nothing similar" (`_no_meaning`). The same-idea +precedents — another project's answer to this idea — need no embedder, and +are tested here. +""" +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.canonical_system import CanonicalSystem +from scribe.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform +from scribe.models.note import Note +from scribe.models.project import Project +from scribe.models.system import RecordSystem, System +from scribe.models.task_log import TaskLog +from scribe.models.user import User +from scribe.services import family as family_svc +from scribe.services import family_adoption as adoption_svc +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + +OWNER = "family_adoption_owner" +OUTSIDER = "family_adoption_outsider" + +CRITERIA = { + "platform_terms": "stated for any Android app that distributes its own APK", + "platform_problem": "signature continuity is the platform's rule, not one app's", + "proven": "shipped and updated in place on a device", +} + + +async def _purge(username: str) -> None: + """SETUP ONLY, as the promotion siblings do: a database call after a + `yield` in an autouse fixture orphans a pooled connection.""" + async with async_session() as s: + for user in (await s.execute(select(User).where(User.username == username))).scalars(): + for note in (await s.execute(select(Note).where(Note.user_id == user.id))).scalars(): + await s.delete(note) + for project in (await s.execute(select(Project).where(Project.user_id == user.id))).scalars(): + await s.delete(project) + await s.commit() + + +@pytest_asyncio.fixture(autouse=True) +async def _clean(): + await _purge(OWNER) + await _purge(OUTSIDER) + + +@pytest.fixture(autouse=True) +def _no_meaning(): + with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])): + yield + + +async def _platform_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 family(): + """Two Android apps and a Go service. The idea is a note in the first app, + filed under a System mapped to a canonical area; the second app has its + own System mapped to the same area, and one that is not. Promoted, so + both apps hold an `unassessed` row.""" + android, go = await _platform_id("android-app"), await _platform_id("go") + async with async_session() as s: + canonical = await s.scalar(select(CanonicalSystem.id).where( + CanonicalSystem.deleted_at.is_(None)).order_by(CanonicalSystem.id).limit(1)) + owner = await ensure_user(s, OWNER) + outsider = await ensure_user(s, OUTSIDER) + a = Project(user_id=owner.id, title="android one") + b = Project(user_id=owner.id, title="android two") + c = Project(user_id=owner.id, title="go service") + s.add_all([a, b, c]) + await s.flush() + s.add_all([ + ProjectPlatform(project_id=a.id, platform_id=android, state="declared"), + ProjectPlatform(project_id=b.id, platform_id=android, state="declared"), + ProjectPlatform(project_id=c.id, platform_id=go, state="declared"), + ]) + a_sys = System(user_id=owner.id, project_id=a.id, name="Release", canonical_id=canonical) + b_sys = System(user_id=owner.id, project_id=b.id, name="Shipping", canonical_id=canonical) + b_other = System(user_id=owner.id, project_id=b.id, name="Unrelated") + pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane", + body="One keystore, two channels, in-place update.") + s.add_all([a_sys, b_sys, b_other, pattern]) + await s.flush() + s.add(RecordSystem(note_id=pattern.id, system_id=a_sys.id)) + await s.commit() + ids = {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id, "c": c.id, + "note": pattern.id, "b_sys": b_sys.id, "b_other": b_other.id} + out = await family_svc.promote( + ids["owner"], ids["note"], applies_when="any Android app that ships its own APK", + platforms=["android-app"], criteria=CRITERIA, + evidence=["CI green on the release lane"], reason="proven, stated for the platform", + ) + assert out["promoted"] is True + return ids + + +async def _assess(f, project: str, outcome: str, reason: str = "the gap", **kw): + if outcome == "adopted": + kw.setdefault("evidence", ["app/build.gradle: the release lane"]) + return await adoption_svc.assess(f["owner"], f[project], f["note"], + outcome=outcome, reason=reason, **kw) + + +async def _row(f, project: str) -> FamilyAdoption: + async with async_session() as s: + return (await s.execute(select(FamilyAdoption).where( + FamilyAdoption.project_id == f[project], FamilyAdoption.idea_id == f["note"]))).scalars().one() + + +async def _task(task_id: int) -> Note: + async with async_session() as s: + return await s.get(Note, task_id) + + +async def _decisions(f, project: str) -> list[FamilyDecision]: + async with async_session() as s: + return list((await s.execute(select(FamilyDecision).where( + FamilyDecision.idea_id == f["note"], FamilyDecision.project_id == f[project]) + .order_by(FamilyDecision.id))).scalars().all()) + + +# --- each state change ---------------------------------------------------------------- + +@pytest.mark.parametrize("outcome", ("adopted", "variant", "exempt")) +async def test_an_answer_moves_the_row_and_logs_one_decision(family, outcome): + out = await _assess(family, "b", outcome, reason="a fact about android two") + row = await _row(family, "b") + assert (row.status, row.reason, row.canon_version, row.decided_via) == ( + outcome, "a fact about android two", 1, "agent") + assert row.assessed_at is not None and row.owed_task_id is None + [decision] = await _decisions(family, "b") + assert decision.action == "assess" + assert decision.before == {"status": "unassessed", "reason": "", "canon_version": None} + assert decision.after["status"] == outcome + assert out["changed"] is True and out["owed_task"] is None + + +async def test_owed_files_a_task_in_the_owing_project_tagged_to_the_matching_system(family): + out = await _assess(family, "b", "owed", reason="no in-place update yet") + row = await _row(family, "b") + assert row.status == "owed" and row.owed_task_id == out["owed_task"]["id"] + task = await _task(row.owed_task_id) + # Filed where the work is owed — never in the idea's own project. + assert task.project_id == family["b"] and task.status == "todo" + assert f"#{family['note']}" in task.body and "no in-place update yet" in task.body + assert "any Android app that ships its own APK" in task.body + async with async_session() as s: + tagged = set((await s.execute(select(RecordSystem.system_id).where( + RecordSystem.note_id == task.id))).scalars().all()) + assert tagged == {family["b_sys"]} + + +async def test_the_owed_task_follows_the_answer(family): + await _assess(family, "b", "owed", reason="not built") + task_id = (await _row(family, "b")).owed_task_id + + await _assess(family, "b", "variant", reason="installs through a managed store") + assert (await _task(task_id)).status == "cancelled" + + out = await _assess(family, "b", "owed", reason="the store plan fell through") + assert out["owed_task"] == {"id": task_id, "title": (await _task(task_id)).title, + "status": "todo", "action": "reopened"} + + await _assess(family, "b", "adopted", reason="built") + assert (await _task(task_id)).status == "done" + async with async_session() as s: + logs = (await s.execute(select(TaskLog.content).where(TaskLog.task_id == task_id) + .order_by(TaskLog.id))).scalars().all() + assert [("variant" in logs[0]), ("owed again" in logs[1]), ("adopted" in logs[2])] == [True] * 3 + # One task across the whole life of the answer. + async with async_session() as s: + filed = (await s.execute(select(Note.id).where( + Note.project_id == family["b"], Note.title.like("Adopt the family idea%")))).scalars().all() + assert filed == [task_id] + + +async def test_the_same_assessment_twice_records_nothing_the_second_time(family): + first = await _assess(family, "b", "owed", reason="not built") + second = await _assess(family, "b", "owed", reason="not built") + assert first["changed"] is True and second["changed"] is False + assert second["decision"] is None + assert second["owed_task"]["id"] == first["owed_task"]["id"] + assert len(await _decisions(family, "b")) == 1 + + +async def test_a_departure_without_a_reason_writes_nothing(family): + with pytest.raises(ValueError, match="exempt needs a reason"): + await _assess(family, "b", "exempt", reason=" ") + with pytest.raises(ValueError, match="adopted needs evidence"): + await _assess(family, "b", "adopted", evidence=[]) + assert (await _row(family, "b")).status == "unassessed" + assert await _decisions(family, "b") == [] + + +async def test_an_idea_that_does_not_reach_the_project_is_refused(family): + with pytest.raises(ValueError, match="not on any of"): + await _assess(family, "c", "exempt", reason="it is a Go service") + + +async def test_only_canon_is_assessed(family): + await family_svc.retire(family["owner"], family["note"], reason="superseded") + with pytest.raises(ValueError, match="not family canon"): + await _assess(family, "a", "adopted") + + +async def test_someone_who_cannot_write_the_project_cannot_answer_for_it(family): + with pytest.raises(ValueError, match="no write access"): + await adoption_svc.assess(family["outsider"], family["b"], family["note"], + outcome="exempt", reason="not mine to say") + + +async def test_another_projects_answer_is_recorded_as_precedent(family): + first = await _assess(family, "a", "adopted", reason="the source app") + second = await _assess(family, "b", "owed", reason="not built") + assert first["decision"]["id"] in second["decision"]["precedent_ids"] + assert second["precedents"][0]["relation"] == "same idea, another project" + assert second["precedents"][0]["project_title"] == "android one" + + +# --- recheck -------------------------------------------------------------------------- + +async def test_a_revision_leaves_every_older_answer_needing_a_recheck(family): + await _assess(family, "a", "adopted") + await _assess(family, "b", "exempt", reason="ships through a store") + out = await family_svc.revise(family["owner"], family["note"], + reason="the keystore now rotates", evidence=["incident"]) + assert out["idea"]["canon_version"] == 2 + rows = await adoption_svc.list_adoptions(family["owner"], idea_id=family["note"]) + assert {r["project_title"]: r["needs_recheck"] for r in rows} == { + "android one": True, "android two": True} + assert [r["project_title"] for r in await adoption_svc.list_adoptions( + family["owner"], idea_id=family["note"], recheck_only=True)] == ["android one", "android two"] + + await _assess(family, "a", "adopted", reason="rotation added") + rows = {r["project_title"]: r for r in await adoption_svc.list_adoptions( + family["owner"], idea_id=family["note"])} + assert rows["android one"]["needs_recheck"] is False + assert rows["android one"]["canon_version"] == 2 + assert rows["android two"]["needs_recheck"] is True + + +async def test_a_revision_needs_canon_and_a_reason(family): + with pytest.raises(ValueError, match="needs a reason"): + await family_svc.revise(family["owner"], family["note"], reason="") + with pytest.raises(ValueError, match="at least one platform"): + await family_svc.revise(family["owner"], family["note"], reason="x", platforms=[]) + + +# --- undo ------------------------------------------------------------------------------ + +async def test_undoing_an_owed_answer_restores_the_row_and_closes_its_task(family): + out = await _assess(family, "b", "owed", reason="not built") + task_id = out["owed_task"]["id"] + decisions = await family_svc.list_decisions(family["owner"], project_id=family["b"]) + assert decisions[0]["undoable"] is True and decisions[0]["project_title"] == "android two" + + await family_svc.undo(family["owner"], out["decision"]["id"], reason="assessed too early") + row = await _row(family, "b") + assert (row.status, row.reason, row.canon_version, row.assessed_at) == ( + "unassessed", None, None, None) + assert (await _task(task_id)).status == "cancelled" + undo_row = (await _decisions(family, "b"))[-1] + assert undo_row.action == "undo" and undo_row.precedent_ids == [out["decision"]["id"]] + + +async def test_only_the_latest_answer_is_undoable(family): + first = await _assess(family, "b", "owed", reason="not built") + await _assess(family, "b", "adopted", reason="built") + with pytest.raises(ValueError, match="came after it"): + await family_svc.undo(family["owner"], first["decision"]["id"], reason="x") + + +# --- the conflict order ------------------------------------------------------------- + +def _checked(ground: str) -> dict: + keys = adoption_svc.CONFLICT_KEYS + return {k: f"{k} does not apply here" for k in keys[:keys.index(ground)]} + + +async def _resolve(f, ground: str, **kw): + kw.setdefault("evidence", ["named in the record"]) + return await adoption_svc.resolve_conflict( + f["owner"], f["note"], canon_project_id=f["a"], other_project_id=f["b"], + ground=ground, grounds_checked=_checked(ground), + fold="Android two signs in CI with a throwaway key.", reason="settled", **kw) + + +@pytest.mark.parametrize("ground,heading,other_outcome", [ + ("operator_stance", "### Alternative — android two's approach", "owed"), + ("covers_failure", "### Trap — android two's approach", "owed"), + ("most_recent_complete", "### Alternative — android two's approach", "owed"), +]) +async def test_a_side_that_loses_is_folded_in_and_owes_the_canon(family, ground, heading, other_outcome): + out = await _resolve(family, ground) + async with async_session() as s: + body = (await s.get(Note, family["note"])).body + idea = await s.get(FamilyIdea, family["note"]) + assert heading in body and "throwaway key" in body + assert idea.canon_version == 2 + assert out["decision"]["action"] == "revise" + assert out["decision"]["evidence"]["conflict"]["ground"] == ground + a, b = await _row(family, "a"), await _row(family, "b") + assert (a.status, a.canon_version) == ("adopted", 2) + assert (b.status, b.canon_version) == (other_outcome, 2) + assert (await _task(b.owed_task_id)).project_id == family["b"] + # Each answer names the revision as the decision it followed. + assert (await _decisions(family, "b"))[-1].precedent_ids == [out["decision"]["id"]] + + +async def test_a_split_makes_each_side_canon_under_its_condition(family): + out = await _resolve(family, "split_by_condition", evidence=None, + conditions={"canon": "the app updates itself", + "other": "a managed store installs it"}) + async with async_session() as s: + body = (await s.get(Note, family["note"])).body + assert "### When a managed store installs it — android two's approach" in body + assert "When the app updates itself, android one's approach above is the canon" in body + a, b = await _row(family, "a"), await _row(family, "b") + assert (a.status, b.status) == ("adopted", "adopted") + assert b.owed_task_id is None and out["owed_task"] is None + + +async def test_a_ground_cannot_skip_the_order_and_nothing_is_written(family): + async with async_session() as s: + body_before = (await s.get(Note, family["note"])).body + with pytest.raises(ValueError, match="'operator_stance' comes before 'covers_failure'"): + await adoption_svc.resolve_conflict( + family["owner"], family["note"], canon_project_id=family["a"], + other_project_id=family["b"], ground="covers_failure", grounds_checked={}, + fold="x", evidence=["incident"], reason="settled") + async with async_session() as s: + assert (await s.get(Note, family["note"])).body == body_before + assert (await s.get(FamilyIdea, family["note"])).canon_version == 1 + + +# --- the matrix ----------------------------------------------------------------------- + +async def test_the_matrix_shows_every_member_and_who_has_not_been_asked(family): + android = await _platform_id("android-app") + async with async_session() as s: + late = Project(user_id=family["owner"], title="android three") + s.add(late) + await s.flush() + s.add(ProjectPlatform(project_id=late.id, platform_id=android, state="detected")) + await s.commit() + late_id = late.id + await _assess(family, "b", "owed", reason="not built") + + matrix = await adoption_svc.adoption_matrix(family["owner"]) + cells = {(c["project_title"]): c for c in matrix["cells"] if c["idea_id"] == family["note"]} + assert set(cells) == {"android one", "android two", "android three"} + assert cells["android two"]["owed_task"]["status"] == "todo" + assert cells["android three"]["reached"] is False and cells["android three"]["status"] == "unassessed" + assert [o["key"] for o in matrix["outcomes"]] == list(adoption_svc.OUTCOME_KEYS) + + # A late member's first answer creates its row. + await adoption_svc.assess(family["owner"], late_id, family["note"], + outcome="exempt", reason="ships through a store") + one = await adoption_svc.adoption_matrix(family["owner"], project_id=late_id) + assert [c["status"] for c in one["cells"]] == ["exempt"] + + # Filtered to a platform no canon idea is for, there is nothing to show. + assert (await adoption_svc.adoption_matrix(family["owner"], platform="go"))["ideas"] == [] + + +async def test_the_matrix_hides_what_the_caller_cannot_read(family): + matrix = await adoption_svc.adoption_matrix(family["outsider"]) + assert all(i["note_id"] != family["note"] for i in matrix["ideas"]) diff --git a/tests/test_routes_family.py b/tests/test_routes_family.py index f8865933..9ad79fca 100644 --- a/tests/test_routes_family.py +++ b/tests/test_routes_family.py @@ -1,4 +1,4 @@ -"""Structural tests for the family blueprint (milestone 463 step 3) — every +"""Structural tests for the family blueprint (milestone 463 steps 3 and 4) — every endpoint is routed, and every write hands the service its caller, where the note's write gate lives. What the writes do is in tests/test_integration_family_promotion.py.""" @@ -18,12 +18,20 @@ def test_family_blueprint_routes_every_endpoint(): ("/api/family/ideas//retire", "POST"), ("/api/family/decisions", "GET"), ("/api/family/decisions//undo", "POST"), + ("/api/family/matrix", "GET"), ): assert (rule, method) in rules, f"{method} {rule} is not routed" def test_every_engine_write_takes_the_caller_and_who_decided(): from scribe.services import family as svc - for name in ("propose", "promote", "retire", "undo"): + for name in ("propose", "promote", "retire", "undo", "revise"): + params = inspect.signature(getattr(svc, name)).parameters + assert "user_id" in params and "decided_via" in params, name + + +def test_every_ledger_write_takes_the_caller_and_who_decided(): + from scribe.services import family_adoption as svc + for name in ("assess", "resolve_conflict", "undo_assessment"): params = inspect.signature(getattr(svc, name)).parameters assert "user_id" in params and "decided_via" in params, name -- 2.54.0 From 233fa3eea99114cafe7d4ef062ed8fc1cb40d529 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 11:11:41 -0400 Subject: [PATCH 07/10] fix(family): the owed task is tagged by the matching System only, and the ground fixtures build without duplicate keys (#4990) assess_family_adoption took system_ids for the task it files, which made the family tool module look like a System-tagging door and tripped the parity registry. It is not one: the owed task is tagged to the project's System matching the idea's canonical area, and update_task retags it. The unit test for each conflict ground passed evidence/conditions twice. Co-Authored-By: Claude Opus 5.5 --- src/scribe/mcp/tools/family.py | 10 +++++----- src/scribe/services/family_adoption.py | 14 ++++++-------- tests/test_family_adoption.py | 2 +- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/scribe/mcp/tools/family.py b/src/scribe/mcp/tools/family.py index 8df85f05..206ad917 100644 --- a/src/scribe/mcp/tools/family.py +++ b/src/scribe/mcp/tools/family.py @@ -253,7 +253,6 @@ async def assess_family_adoption( reason: str, evidence: list[str] | None = None, precedent_ids: list[int] | None = None, - system_ids: list[int] | None = None, ) -> dict: """Answer one canon family idea for one project. YOU decide; nobody approves. Judge in this order and stop at the first that holds: @@ -268,8 +267,10 @@ async def assess_family_adoption( 3. adopted — it applies and the project does it. `evidence` names where (a file, a commit, a task, a CI run). 4. owed — none of the above. A task is filed in THIS project naming the - gap and the reference implementation for its language. Nothing edits - another repository; the project picks the task up itself. + gap and the reference implementation for its language, filed under + the project's System matching the idea's area (retag it with + update_task if that guess is wrong). Nothing edits another + repository; the project picks the task up itself. Read get_family_adoption first: answer consistently with its precedents unless this project differs in a way you can name in `reason`. The @@ -286,11 +287,10 @@ async def assess_family_adoption( reason: why — required for every outcome. evidence: where it is done (required for adopted), or what you checked. precedent_ids: earlier family decisions you followed, if any. - system_ids: Systems for an owed task. Omit to match the idea's own. """ return await adoption_svc.assess( current_user_id(), project_id, idea_id, outcome=outcome, reason=reason, - evidence=evidence, precedent_ids=precedent_ids, system_ids=system_ids, + evidence=evidence, precedent_ids=precedent_ids, ) diff --git a/src/scribe/services/family_adoption.py b/src/scribe/services/family_adoption.py index 72c6617f..493e9656 100644 --- a/src/scribe/services/family_adoption.py +++ b/src/scribe/services/family_adoption.py @@ -519,8 +519,7 @@ async def _matching_systems(session, project_id: int, note_ids: list[int]) -> li return list(dict.fromkeys(direct + list(mapped))) -async def _file_owed_task(user_id: int, row_id: int, reason: str, - system_ids: list[int] | None) -> Note: +async def _file_owed_task(user_id: int, row_id: int, reason: str) -> Note: from scribe.services import notes as notes_svc from scribe.services import systems as systems_svc @@ -531,7 +530,7 @@ async def _file_owed_task(user_id: int, row_id: int, reason: str, refs = await _references(session, row.idea_id) langs = await _project_languages(session, row.project_id) platforms = await family_svc._platform_slugs(session, row.idea_id) - systems = system_ids or await _matching_systems( + systems = await _matching_systems( session, row.project_id, [row.idea_id] + [r["id"] for r in refs]) project_id, idea_id, version = row.project_id, row.idea_id, idea.canon_version body = ( @@ -568,8 +567,7 @@ async def _set_task_status(user_id: int, task: Note, status: str, log: str) -> N await task_logs.create_log(user_id, task.id, log) -async def _sync_owed_task(user_id: int, row_id: int, *, reason: str, - system_ids: list[int] | None = None) -> dict | None: +async def _sync_owed_task(user_id: int, row_id: int, *, reason: str) -> dict | None: """Make the owed task agree with the answer. Idempotent: an `owed` row with an open task, or a settled row with a closed one, is left alone. A task the caller cannot write is left alone too, and said so.""" @@ -594,7 +592,7 @@ async def _sync_owed_task(user_id: int, row_id: int, *, reason: str, user_id, task, TaskStatus.todo.value, f"Reopened: family idea #{idea_id} was assessed owed again — {reason}") return ref(task, TaskStatus.todo.value, "reopened") - task = await _file_owed_task(user_id, row_id, reason, system_ids) + task = await _file_owed_task(user_id, row_id, reason) async with async_session() as session: row = await session.get(FamilyAdoption, row_id) row.owed_task_id = task.id @@ -645,7 +643,7 @@ def _answer(row: FamilyAdoption, *, status: str, reason: str, version: int, async def assess( user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str, evidence: list[str] | None = None, precedent_ids: list[int] | None = None, - system_ids: list[int] | None = None, decided_via: str = "agent", + decided_via: str = "agent", ) -> dict: """Record one project's answer to one canon idea. @@ -692,7 +690,7 @@ async def assess( if decision is not None: await session.refresh(decision) row_id = row.id - task = await _sync_owed_task(user_id, row_id, reason=reason, system_ids=system_ids) + task = await _sync_owed_task(user_id, row_id, reason=reason) rows = await list_adoptions(user_id, project_id=project_id, idea_id=idea_id) return { "changed": changed, diff --git a/tests/test_family_adoption.py b/tests/test_family_adoption.py index 63a59636..585872ef 100644 --- a/tests/test_family_adoption.py +++ b/tests/test_family_adoption.py @@ -62,7 +62,7 @@ GOOD = { @pytest.mark.parametrize("ground", CONFLICT_KEYS) def test_each_ground_holds_when_its_needs_are_met(ground): - kw = dict(evidence=None, conditions=None, **{**GOOD[ground]}) + kw = {"evidence": None, "conditions": None, **GOOD[ground]} assert conflict_problems(ground=ground, grounds_checked=_checked(ground), fold="their reasoning", **kw) == [] -- 2.54.0 From 5f41dbd28327008ca5b45e72702b90adfcd96975 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 13:06:45 -0400 Subject: [PATCH 08/10] feat(family): the shape ledger judges against family ideas, across languages (milestone 463 step 5, #4991) - classify_shapes takes idea_id: the shape is judged against the idea's reference in its language, or its first. - A shape classified against a canon idea's reference moves the project's adoption row (instance -> adopted, variant -> variant). Withdrawing the shapes that gave an answer returns it to unassessed. This runs on classify_shapes, the sweep, confirm_proposals and the coverage refresh. - assess and undo refuse an answer the shapes contradict. A conflict resolution re-judges the variant shapes on both sides. - The proposer's family arm offers a canon idea's reference on a shared platform, in any language. It proposes only when the reference is the top hit over every readable snippet and scores at least 0.70. The pairs this was measured on are recorded beside _FAMILY_FLOOR. The proposer is now v6. - The matrix cell shows the code that answers it. Co-Authored-By: Claude Opus 5.5 --- frontend/src/api/family.ts | 2 + .../src/components/FamilyAdoptionMatrix.vue | 12 + src/scribe/mcp/tools/family.py | 6 + src/scribe/mcp/tools/shapes.py | 48 ++- src/scribe/services/coverage.py | 10 + src/scribe/services/family_adoption.py | 302 +++++++++++++++++- src/scribe/services/shape_ledger.py | 147 ++++++++- tests/test_divergence_meaning_gate.py | 4 +- tests/test_family_shapes.py | 142 ++++++++ tests/test_integration_family_adoption.py | 188 +++++++++++ 10 files changed, 823 insertions(+), 38 deletions(-) create mode 100644 tests/test_family_shapes.py diff --git a/frontend/src/api/family.ts b/frontend/src/api/family.ts index a26b1c9e..5162b578 100644 --- a/frontend/src/api/family.ts +++ b/frontend/src/api/family.ts @@ -126,6 +126,8 @@ export interface AdoptionCell { /** Answered against a canon version that is not the current one. */ needs_recheck: boolean; owed_task: { id: number; title: string; status: string } | null; + /** Live shapes in the project classified against one of the idea's references. */ + shapes: { path: string; symbol: string; status: "canonical" | "instance" | "variant" }[]; } export interface MatrixIdea { diff --git a/frontend/src/components/FamilyAdoptionMatrix.vue b/frontend/src/components/FamilyAdoptionMatrix.vue index e897f778..8adfd707 100644 --- a/frontend/src/components/FamilyAdoptionMatrix.vue +++ b/frontend/src/components/FamilyAdoptionMatrix.vue @@ -218,6 +218,15 @@ defineExpose({ load }); ({{ selected.owed_task.status.replace("_", " ") }})

+
+

In this project's code — the shape ledger classifies:

+
    +
  • + {{ s.path }} · {{ s.symbol }} + {{ s.status === "variant" ? "variant" : "instance" }} +
  • +
+

Answered {{ fmtStamp(selected.assessed_at) }} @@ -320,6 +329,9 @@ button.fam-cell:hover { background: var(--fs-surface-hover); } .fam-detail p { margin: 0.4rem 0 0; font-size: 0.875rem; } .fam-detail-reason { color: var(--fs-text-primary); } .fam-detail-recheck { color: var(--fs-text-secondary); } +.fam-detail-shapes ul { margin: 0.25rem 0 0; padding-left: 1.1rem; font-size: 0.85rem; } +.fam-detail-shapes li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 0.5rem; overflow-wrap: anywhere; } +.fam-detail-shapes code { font-family: var(--fs-font-mono); } .fam-outcomes { margin-top: 0.75rem; font-size: 0.85rem; color: var(--fs-text-secondary); } .fam-outcomes summary { cursor: pointer; color: var(--fs-text-secondary); } .fam-outcomes dl { margin: 0.5rem 0 0; } diff --git a/src/scribe/mcp/tools/family.py b/src/scribe/mcp/tools/family.py index 206ad917..da157c85 100644 --- a/src/scribe/mcp/tools/family.py +++ b/src/scribe/mcp/tools/family.py @@ -280,6 +280,12 @@ async def assess_family_adoption( variant cancels it, owed again reopens it. The same answer given twice records nothing the second time. + When the project's code is in the shape ledger, prefer answering THERE: + classify_shapes the shape against the idea (idea_id=…) as an instance or + a variant, and this answer moves with it. An answer the shapes already + give otherwise is refused — the two ledgers never disagree; reclassify + the shapes if they are wrong. + Args: project_id: the project answering. idea_id: the canon idea. diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index cab11589..f2bd4c77 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -37,10 +37,15 @@ async def classify_shapes( Args: project_id: The project whose ledger is being judged. classifications: Objects of {path, symbol, status, kind?, snippet_id?, - reason?, reason_code?}. path+symbol name the shape exactly as + idea_id?, reason?, reason_code?}. path+symbol name the shape exactly as list_shapes shows it; kind ("sym"/"css") narrows when one file defines both. snippet_id is required for canonical/instance/ - variant; reason is required for variant/exempt. reason_code is + variant — or idea_id, a family idea: the shape is then judged + against that idea's reference implementation in the shape's + language, or its first reference when none is in that language + (an idea is shared across languages: a Python shape can be an + instance of an idea whose reference is Go). reason is required + for variant/exempt. reason_code is an OPTIONAL index beside the prose (one of: scoped-css, one-off-handler, test-helper, convention-plumbing, pure-helper, generated, script, typed-record) so the ledger can be filtered @@ -60,11 +65,20 @@ async def classify_shapes( confirms it. Must be bound to the project. Omit it to judge only synced rows. + FAMILY CANON FOLLOWS. A shape judged against a canon family idea's + reference IS the project's answer to that idea: an instance answers it + `adopted`, a variant `variant` (with the shape's reason), and + withdrawing the shapes that gave an answer returns it to `unassessed`. + The adoption ledger is moved for you — `family` in the result lists the + answers that moved — and assess_family_adoption refuses an answer the + shapes contradict. + All-or-nothing: a structural error, a missing snippet target, an unbound repo, or no write access applies NOTHING. Returns {"classified": N, - "unmatched": [...], "provisional": N?} — unmatched names shapes no live - ledger row matches (the tree may have moved since you listed; pass `repo` - for a shape you just wrote, or re-run the project's coverage refresh). + "unmatched": [...], "provisional": N?, "family": [...]?} — unmatched names + shapes no live ledger row matches (the tree may have moved since you + listed; pass `repo` for a shape you just wrote, or re-run the project's + coverage refresh). """ uid = current_user_id() return await shape_ledger_svc.classify_shapes( @@ -116,7 +130,10 @@ async def list_shapes( machine thinks are an instance of a snippet: `proposal` carries snippet_id, basis, score), "derive" (rows that repeat with NO canon: `proposal.group` names the family), or one basis - (symbol/text/reference/signature/semantic). + (symbol/text/reference/signature/semantic/family). "family" is + a canon family idea's reference — another project's, often + another language's — that the body means: the top match over + every snippet you can read, on a platform the project shares. flag: the divergence readout (#2793) — "divergence": shapes new since the previous refresh in a directory where one canon dominates the judged siblings and NOT proposed as that canon @@ -145,7 +162,8 @@ async def list_shapes( THE FAST PATH through a big todo is the proposer's queue: every coverage refresh matches unclassified shapes against canon (strongest basis first: same symbol elsewhere → textual containment → body references - the canon → signature resemblance → semantic) and attaches a + the canon → signature resemblance → semantic, and a family idea's + reference in any language) and attaches a `proposal` to each row it can speak for. Review `proposal="canon"` by snippet or directory, then confirm_shape_proposals the ones that hold — hundreds at a time — and classify_shapes the rest (variant/exempt, or @@ -214,9 +232,11 @@ async def classify_shapes_by_rule( revise a family you judged earlier). One transaction: applies whole or not at all. Returns - {"classified": N, "sample": ["path::symbol", ...]} (first 12, sorted) - so you can see what the rule reached; N = 0 means the rule matched - nothing live and unclassified — widen the pattern or refresh coverage. + {"classified": N, "sample": ["path::symbol", ...], "family": [...]?} + (sample: first 12, sorted) so you can see what the rule reached; N = 0 + means the rule matched nothing live and unclassified — widen the pattern + or refresh coverage. As with classify_shapes, a family idea's reference + judged here moves the project's answer to that idea (`family`). """ uid = current_user_id() try: @@ -317,12 +337,14 @@ async def confirm_shape_proposals( snippet_id (confirm one canon's whole queue after reading its `list_shapes(proposal="canon", ...)` page), path (a directory you audited), or basis (e.g. "symbol" and "reference" are near-certain; - "semantic" deserves a look first) is required — a bare confirm-all is - not a judgment. min_score trims a basis's tail. + "semantic" and "family" deserve a look first) is required — a bare + confirm-all is not a judgment. min_score trims a basis's tail. Proposals you do NOT confirm are judged with classify_shapes (variant, exempt, or instance of a different snippet) — any judgment retires the - proposal. Requires write access. Returns {"confirmed": N}. + proposal. A confirmed family reference answers that idea `adopted` in + the project (`family` lists the answers that moved). Requires write + access. Returns {"confirmed": N, "family": [...]?}. """ uid = current_user_id() try: diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index 7f8e3b28..36bd0656 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -879,6 +879,16 @@ async def compute_coverage( logger.warning("platform detection failed for project %s", project_id, exc_info=True) await shape_ledger.mark_canonicals(project_id, recorded) + # The adoption ledger follows the shapes (milestone 463 step 5). The + # judge paths sync the ideas they touch; the refresh is the one place + # that sees canonical stamps land and shapes vanish, so it answers every + # canon idea reaching the project. It must not fail the refresh. + try: + from scribe.services import family_adoption + + await family_adoption.sync_from_shapes(user_id, project_id) + except Exception: + logger.warning("family adoption sync failed for project %s", project_id, exc_info=True) try: await shape_ledger.apply_derive_groups(project_id) except Exception: diff --git a/src/scribe/services/family_adoption.py b/src/scribe/services/family_adoption.py index 493e9656..6d3f9103 100644 --- a/src/scribe/services/family_adoption.py +++ b/src/scribe/services/family_adoption.py @@ -43,13 +43,26 @@ applies wins, and every ground above it must be said not to: The losing side's reasoning is folded into the idea's note — as a trap, an alternative, or the branch for its condition — and is never dropped. The substance changed, so the version moves. + +SHAPES (step 5). The shape ledger answers too: a shape classified against an +idea's reference — in any language — is the project's code saying `adopted` +(an instance) or `variant` (with the shape's reason). The two ledgers never +disagree: a classification moves the row, an assessment or undo the shapes +contradict is refused, an answer the shapes gave is withdrawn with them, and +a conflict resolution re-judges the variant shapes on both sides. The shape +proposer offers an idea's reference across projects and languages by +meaning; its rule and the measurement behind it sit above +shape_ledger._FAMILY_FLOOR. """ from __future__ import annotations import logging +from typing import Iterable + from sqlalchemy import func, select from scribe.models import async_session +from scribe.models.code_shape import CodeShape from scribe.models.family import ( FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference, Platform, ProjectPlatform, @@ -281,6 +294,224 @@ async def _project_languages(session, project_id: int) -> list[str]: return [r[0] for r in rows] +# --- the shape ledger as evidence (milestone 463 step 5) ---------------------------- +# +# A shape classified against an idea's reference IS an answer to the idea: an +# `instance` — or the reference's own `canonical` location — says the project +# does it; a `variant` says it departs, for the reason the shape carries. The +# two ledgers must never disagree, and the shapes are the stronger evidence +# (they name the code), so: +# +# - classifying a shape moves the adoption row to what the shapes say, +# decided_via "system", the shapes as evidence; +# - an assessment or an undo that would contradict the shapes is refused — +# reclassify the shapes if they are wrong; +# - an answer the shape ledger gave is withdrawn to `unassessed` when the +# shapes that gave it are withdrawn. An answer given on other evidence is +# left alone: no shape is not the same as `owed`. + +SHAPE_SOURCE = "shape_ledger" +_SHAPE_ADOPTS = ("canonical", "instance") + +# Which reference a shape is judged against when it is classified against an +# idea: the one in the shape's language when there is one. +_EXTS_BY_LANGUAGE = { + "python": (".py", ".pyi"), "go": (".go",), "kotlin": (".kt", ".kts"), + "java": (".java",), "dart": (".dart",), "swift": (".swift",), "rust": (".rs",), + "typescript": (".ts", ".tsx"), "javascript": (".js", ".jsx", ".mjs", ".cjs"), + "vue": (".vue",), "svelte": (".svelte",), "css": (".css",), "scss": (".scss",), + "bash": (".sh", ".bash"), "sh": (".sh",), "sql": (".sql",), +} + + +def path_speaks(path: str, language: str) -> bool: + """Is a file at ``path`` written in ``language`` (a snippet's recorded one)?""" + exts = _EXTS_BY_LANGUAGE.get((language or "").strip().lower()) + return bool(exts) and (path or "").strip().lower().endswith(exts) + + +def shape_outcome(statuses: Iterable[str]) -> str | None: + """What a project's shapes say about an idea: `adopted` when any shape is + an instance of (or is) a reference, `variant` when the only ones are + variants, None when no shape speaks. None is silence, never `owed`.""" + seen = set(statuses) + if seen & set(_SHAPE_ADOPTS): + return "adopted" + if "variant" in seen: + return "variant" + return None + + +def shapes_say(rows) -> dict | None: + """The answer a project's evidence rows give — outcome, reason, evidence + lines and the shapes themselves — or None when no shape speaks. The + `adopted` reason is fixed text, so classifying one more instance does not + log a new decision; a `variant` carries the first variant shape's why.""" + outcome = shape_outcome(r.status for r in rows) + if outcome is None: + return None + speaking = [r for r in rows if (r.status in _SHAPE_ADOPTS) == (outcome == "adopted")] + if outcome == "adopted": + reason = "the shape ledger classifies this project's code as the idea's reference implementation" + else: + first = speaking[0] + reason = (f"the shape ledger classifies {first.path}::{first.symbol} as a variant " + f"of #{first.snippet_id}: {(first.reason or '').strip()}") + return { + "outcome": outcome, + "reason": reason, + "evidence": [f"{r.path}::{r.symbol}" for r in speaking], + "shapes": [{"id": r.id, "path": r.path, "symbol": r.symbol, "status": r.status, + "snippet_id": r.snippet_id} for r in speaking], + } + + +async def _shape_evidence(session, project_id: int, idea_id: int) -> list[CodeShape]: + """The project's live shapes judged against any of the idea's references.""" + refs = select(FamilyIdeaReference.snippet_id).where(FamilyIdeaReference.idea_id == idea_id) + return list((await session.execute( + select(CodeShape).where( + CodeShape.project_id == project_id, + CodeShape.vanished_at.is_(None), + CodeShape.snippet_id.in_(refs), + CodeShape.status.in_(_SHAPE_ADOPTS + ("variant",)), + ).order_by(CodeShape.path, CodeShape.symbol) + )).scalars().all()) + + +def _contradiction(outcome: str, said: dict | None, idea_id: int) -> str | None: + if said is None or outcome == said["outcome"]: + return None + named = ", ".join(said["evidence"][:3]) + (" …" if len(said["evidence"]) > 3 else "") + return ( + f"the shape ledger already answers #{idea_id} in this project as {said['outcome']} " + f"({named}). The two ledgers never disagree: reclassify those shapes " + "(classify_shapes) if they are wrong, and the answer follows") + + +async def reference_for(user_id: int, idea_id: int, path: str) -> int: + """The reference snippet a shape at ``path`` is judged against when it is + classified against a family idea: the one in the path's language when + there is one, else the first. An idea is shared across languages, so a + Python shape can be an instance of an idea whose only reference is Go. + Raises ValueError when the idea is not a family idea the caller can read + or has no reference they can read.""" + if not await access.can_read_note(user_id, idea_id): + raise ValueError(f"#{idea_id} is not a family idea you can read") + async with async_session() as session: + if await session.get(FamilyIdea, idea_id) is None: + raise ValueError(f"#{idea_id} is not a family idea") + refs = await _references(session, idea_id) + readable = [r for r in refs if await access.can_read_note(user_id, r["id"])] + if not readable: + raise ValueError( + f"family idea #{idea_id} has no reference implementation you can read — " + "set_family_references first, or classify against a snippet_id") + for r in readable: + if path_speaks(path, r["language"]): + return r["id"] + return readable[0]["id"] + + +async def family_reference_ids(project_id: int) -> set[int]: + """The reference snippets of every canon idea that reaches the project — + what the shape proposer may match across projects and languages.""" + async with async_session() as session: + return set((await session.execute( + select(FamilyIdeaReference.snippet_id) + .join(FamilyIdea, FamilyIdea.note_id == FamilyIdeaReference.idea_id) + .join(FamilyIdeaPlatform, FamilyIdeaPlatform.note_id == FamilyIdea.note_id) + .join(ProjectPlatform, ProjectPlatform.platform_id == FamilyIdeaPlatform.platform_id) + .where( + FamilyIdea.status == "canon", + ProjectPlatform.project_id == project_id, + ProjectPlatform.state.in_(family_svc.MEMBER_STATES), + ) + )).scalars().all()) + + +async def _latest_source(session, project_id: int, idea_id: int) -> str: + latest = (await session.execute( + select(FamilyDecision).where( + FamilyDecision.idea_id == idea_id, FamilyDecision.project_id == project_id) + .order_by(FamilyDecision.id.desc()).limit(1) + )).scalars().first() + return str(((latest.evidence if latest is not None else None) or {}).get("source") or "") + + +async def _withdraw_shape_answer(user_id: int, project_id: int, idea_id: int) -> FamilyDecision: + reason = ("the shapes that answered this idea were withdrawn from the shape ledger; " + "it is unassessed again") + async with async_session() as session: + row = await _row(session, project_id, idea_id) + before = _row_state(row) + row.status = "unassessed" + row.reason = None + row.canon_version = None + row.assessed_at = None + row.decided_via = None + row.updated_at = family_svc._now() + decision = family_svc._log( + session, idea_id=idea_id, project_id=project_id, action="assess", + reason=reason, before=before, after=_row_state(row), + evidence={"source": SHAPE_SOURCE, "shapes": []}, precedent_ids=None, + decided_via="system", user_id=user_id, + ) + await session.commit() + await session.refresh(decision) + row_id = row.id + await _sync_owed_task(user_id, row_id, reason=reason) + return decision + + +async def sync_from_shapes(user_id: int, project_id: int, + snippet_ids: Iterable[int] | None = None) -> list[dict]: + """Bring the project's adoption rows into line with its shape ledger, for + the canon ideas whose references are among ``snippet_ids`` — or every + canon idea that reaches the project when None (the coverage refresh, + which also sees shapes vanish). + + A row that already gives the shapes' outcome at the current version is + left alone, whatever evidence it was given on. Returns one entry per + answer that moved: {idea_id, status, decision_id}.""" + query = select(FamilyIdea.note_id).where(FamilyIdea.status == "canon") + if snippet_ids is not None: + wanted = {int(s) for s in snippet_ids if s} + if not wanted: + return [] + query = query.where(FamilyIdea.note_id.in_( + select(FamilyIdeaReference.idea_id).where(FamilyIdeaReference.snippet_id.in_(wanted)))) + async with async_session() as session: + idea_ids = [i for i in (await session.execute(query)).scalars().all() + if await _is_member(session, project_id, i)] + moved: list[dict] = [] + for idea_id in sorted(idea_ids): + if not await access.can_read_note(user_id, idea_id): + continue + async with async_session() as session: + idea = await session.get(FamilyIdea, idea_id) + said = shapes_say(await _shape_evidence(session, project_id, idea_id)) + row = await _row(session, project_id, idea_id) + current = _row_state(row) if row is not None else None + source = await _latest_source(session, project_id, idea_id) + if said is not None: + if current and current["status"] == said["outcome"] \ + and current["canon_version"] == idea.canon_version: + continue + result = await assess( + user_id, project_id, idea_id, outcome=said["outcome"], reason=said["reason"], + evidence=said["evidence"], decided_via="system", source=SHAPE_SOURCE, + shapes=said["shapes"], + ) + if result["changed"]: + moved.append({"idea_id": idea_id, "status": said["outcome"], + "decision_id": result["decision"]["id"]}) + elif current and current["status"] in ("adopted", "variant") and source == SHAPE_SOURCE: + decision = await _withdraw_shape_answer(user_id, project_id, idea_id) + moved.append({"idea_id": idea_id, "status": "unassessed", "decision_id": decision.id}) + return moved + + async def assessment_precedents(user_id: int, project_id: int, idea_id: int, limit: int = 5) -> list[dict]: """What an assessment should be consistent with: the latest answer to @@ -388,6 +619,20 @@ async def adoption_matrix( select(Note).where(Note.id.in_(task_ids), Note.deleted_at.is_(None)) )).scalars().all() } if task_ids else {} + # The code that answers each cell (step 5): live shapes classified + # against one of the idea's references. + evidence: dict[tuple[int, int], list[dict]] = {} + for iid, shape in (await session.execute( + select(FamilyIdeaReference.idea_id, CodeShape) + .join(CodeShape, CodeShape.snippet_id == FamilyIdeaReference.snippet_id) + .where(FamilyIdeaReference.idea_id.in_(idea_ids), + CodeShape.project_id.in_(list(project_ids)), + CodeShape.vanished_at.is_(None), + CodeShape.status.in_(_SHAPE_ADOPTS + ("variant",))) + .order_by(CodeShape.path, CodeShape.symbol) + )).all() if idea_ids and project_ids else []: + evidence.setdefault((shape.project_id, iid), []).append( + {"path": shape.path, "symbol": shape.symbol, "status": shape.status}) readable = [(pid, title) for pid, title in projects if await access.can_read_project(user_id, pid)] @@ -415,6 +660,7 @@ async def adoption_matrix( "needs_recheck": bool(row) and needs_recheck( row.status, row.canon_version, idea.status, idea.canon_version), "owed_task": None, + "shapes": evidence.get((pid, iid), []), } task = tasks.get(row.owed_task_id) if row and row.owed_task_id else None if task is not None: @@ -643,13 +889,17 @@ def _answer(row: FamilyAdoption, *, status: str, reason: str, version: int, async def assess( user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str, evidence: list[str] | None = None, precedent_ids: list[int] | None = None, - decided_via: str = "agent", + decided_via: str = "agent", source: str = "", shapes: list[dict] | None = None, ) -> dict: """Record one project's answer to one canon idea. Raises ValueError before writing anything when the answer is malformed - (assessment_problems), the caller cannot write the project, or the idea - is not canon or does not reach the project. + (assessment_problems), the caller cannot write the project, the idea + is not canon or does not reach the project, or the project's shape + ledger already answers the idea otherwise (the two never disagree). + + ``source``/``shapes`` are the shape ledger's own door (sync_from_shapes): + the decision's evidence then names the shapes it was read from. The same answer given again — same outcome, reason and canon version — records nothing (`changed: False`); it still makes sure an owed answer @@ -673,6 +923,11 @@ async def assess( if idea is None or idea.status != "canon": raise ValueError(f"#{idea_id} is not family canon — only canon is assessed") row = await _row_for_write(session, project_id, idea_id) + if source != SHAPE_SOURCE: + refused = _contradiction( + outcome, shapes_say(await _shape_evidence(session, project_id, idea_id)), idea_id) + if refused: + raise ValueError(refused) before = _row_state(row) after = {"status": outcome, "reason": reason.strip(), "canon_version": idea.canon_version} changed = before != after @@ -683,8 +938,9 @@ async def assess( decision = family_svc._log( session, idea_id=idea_id, project_id=project_id, action="assess", reason=reason, before=before, after=_row_state(row), - evidence={"evidence": evidence}, precedent_ids=precedent_list, - decided_via=decided_via, user_id=user_id, + evidence=({"evidence": evidence, "source": source, "shapes": shapes or []} + if source else {"evidence": evidence}), + precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id, ) await session.commit() if decision is not None: @@ -722,6 +978,11 @@ async def undo_assessment(user_id: int, target: FamilyDecision, *, reason: str, raise ValueError(f"decision {target.id} cannot be undone: the answer it changed is gone") before = _row_state(row) prior = target.before or {"status": "unassessed", "reason": "", "canon_version": None} + refused = _contradiction( + prior["status"], shapes_say(await _shape_evidence(session, target.project_id, target.idea_id)), + target.idea_id) + if refused: + raise ValueError(f"decision {target.id} cannot be undone: {refused}") row.status = prior["status"] row.reason = (prior.get("reason") or "").strip() or None row.canon_version = prior.get("canon_version") @@ -821,7 +1082,9 @@ async def resolve_conflict( the grounds checked above it); the canon side is answered `adopted`, and the other side `owed` (with a task filed) or, in a split, `adopted` under its condition. Each answer is its own `assess` decision, naming the - revision as its precedent. + revision as its precedent. Last, either side's shapes classified as a + variant of the idea are re-judged to agree with its new answer + (`shapes_rejudged`), so the shape ledger and this one do not disagree. """ from scribe.services import notes as notes_svc @@ -915,6 +1178,9 @@ async def resolve_conflict( row_ids[pid] = row.id await session.commit() await session.refresh(revision) + # The shapes follow the resolution, so the two ledgers still agree. + rejudged = {pid: await _shapes_follow(user_id, pid, idea_id, outcome, why) + for pid, outcome, why in answers} task = await _sync_owed_task(user_id, row_ids[other_project_id], reason=answers[1][2]) await _sync_owed_task(user_id, row_ids[canon_project_id], reason=answers[0][2]) return { @@ -923,4 +1189,28 @@ async def resolve_conflict( "folded_as": g["fold_as"], "adoptions": await list_adoptions(user_id, idea_id=idea_id), "owed_task": task, + "shapes_rejudged": {str(pid): n for pid, n in rejudged.items() if n}, } + + +async def _shapes_follow(user_id: int, project_id: int, idea_id: int, outcome: str, + why: str) -> int: + """Re-judge a project's variant shapes of the idea to agree with the + answer a conflict resolution just gave it: `adopted` makes them instances + of the reference they departed from (in a split, their condition is now + canon); `owed` returns them to the shape todo, to be rebuilt to the + canon the owed task names. Returns how many rows were re-judged.""" + from scribe.services import shape_ledger + + async with async_session() as session: + rows = [r for r in await _shape_evidence(session, project_id, idea_id) + if r.status == "variant"] + if not rows: + return 0 + status = "instance" if outcome == "adopted" else "unclassified" + result = await shape_ledger.classify_shapes(user_id, project_id, [ + {"path": r.path, "symbol": r.symbol, "kind": r.kind, "status": status, + "snippet_id": r.snippet_id, "reason": why} + for r in rows + ], via="agent") + return int(result.get("classified") or 0) diff --git a/src/scribe/services/shape_ledger.py b/src/scribe/services/shape_ledger.py index b86368d3..9a7f5285 100644 --- a/src/scribe/services/shape_ledger.py +++ b/src/scribe/services/shape_ledger.py @@ -514,7 +514,7 @@ def validate_classifications(items: list[dict]) -> str | None: snippet_id = item.get("snippet_id") or 0 if status in _NEEDS_TARGET and not snippet_id: return ( - f"classifications[{i}]: status {status!r} needs snippet_id — " + f"classifications[{i}]: status {status!r} needs snippet_id (or idea_id) — " "the snippet this shape is (or departs from)" ) if status in ("variant", "exempt") and not (item.get("reason") or "").strip(): @@ -570,6 +570,7 @@ async def classify_shapes( if via not in _CALLER_VIAS: raise ValueError(f"via must be one of: {', '.join(_CALLER_VIAS)}") + classifications = await _resolve_ideas(user_id, classifications) error = validate_classifications(classifications) if error: raise ValueError(error) @@ -602,6 +603,7 @@ async def classify_shapes( classified = 0 provisional = 0 unmatched: list[dict] = [] + touched: set[int] = set() async with async_session() as session: rows = ( await session.execute( @@ -639,6 +641,7 @@ async def classify_shapes( continue status = item["status"] for row in matches: + touched.update(x for x in (row.snippet_id, item.get("snippet_id")) if x) await _judge( session, row, status=status, snippet_id=int(item["snippet_id"]) if status in _NEEDS_TARGET else None, @@ -653,9 +656,52 @@ async def classify_shapes( out = {"classified": classified, "unmatched": unmatched} if provisional: out["provisional"] = provisional + moved = await _sync_family(user_id, project_id, touched) + if moved: + out["family"] = moved return out +async def _resolve_ideas(user_id: int, items: list[dict]) -> list[dict]: + """An item may name a family idea (`idea_id`) in place of a snippet + (milestone 463 step 5): it is judged against the idea's reference in the + shape's language, or its first when there is none in that language — an + idea is shared across languages. An explicit snippet_id wins.""" + from scribe.services import family_adoption + + out = [] + for i, item in enumerate(items or []): + if (isinstance(item, dict) and item.get("idea_id") and not item.get("snippet_id") + and item.get("status") in _NEEDS_TARGET): + try: + sid = await family_adoption.reference_for( + user_id, int(item["idea_id"]), (item.get("path") or "").strip()) + except (TypeError, ValueError) as exc: + raise ValueError(f"classifications[{i}]: {exc}") from None + item = {**item, "snippet_id": sid} + out.append(item) + return out + + +async def _sync_family(user_id: int, project_id: int, snippet_ids: set[int]) -> list[dict]: + """The adoption ledger follows the shapes (milestone 463 step 5): after a + judgment commits, the canon ideas whose references it touched — as the + new target or the one it replaced — are answered from the shapes. + + A failure is logged and the judgment stands; the next judgment touching + those references, or the next coverage refresh, brings the two back in + line.""" + from scribe.services import family_adoption + + if not snippet_ids: + return [] + try: + return await family_adoption.sync_from_shapes(user_id, project_id, snippet_ids) + except Exception: + logger.warning("family adoption sync failed for project %s", project_id, exc_info=True) + return [] + + def rule_matches(row: CodeShape, *, path: str, pattern: str, kind: str) -> bool: """Does a ledger row fall under a rule-form classification (#2868)? ``path`` is a file or a directory (everything beneath it), ``pattern`` @@ -721,6 +767,7 @@ async def classify_shapes_where( now = datetime.now(timezone.utc) judged: list[str] = [] + touched: set[int] = {int(snippet_id)} if snippet_id and status in _NEEDS_TARGET else set() async with async_session() as session: conds = [CodeShape.project_id == project_id, CodeShape.vanished_at.is_(None)] if not include_judged: @@ -729,6 +776,8 @@ async def classify_shapes_where( for row in rows: if not rule_matches(row, path=path, pattern=pattern, kind=kind): continue + if row.snippet_id: + touched.add(row.snippet_id) await _judge( session, row, status=status, snippet_id=int(snippet_id) if status in _NEEDS_TARGET else None, @@ -738,7 +787,11 @@ async def classify_shapes_where( await record_uses(session, row, uses, basis=via, evidence=reason) judged.append(f"{row.path}::{row.symbol}") await session.commit() - return {"classified": len(judged), "sample": sorted(judged)[:12]} + out = {"classified": len(judged), "sample": sorted(judged)[:12]} + moved = await _sync_family(user_id, project_id, touched) + if moved: + out["family"] = moved + return out async def list_project_shapes( @@ -1611,6 +1664,29 @@ _SEMANTIC_LIMIT = 8 # against a prose-forward snippet document measures the wrong field (#2518); # no floor separates the two bands. The arm PROPOSES on a hit; its silence # says nothing. +# +# THE FAMILY ARM (milestone 463 step 5). The arm above is held to the shape's +# own project and language family, because across projects it was pure noise +# (#2871). A family idea is the exception the hold was waiting for: an idea +# shared across projects ON PURPOSE, whose reference may be in another +# language (a Python throttle is an instance of a Go one). So the arm also +# proposes the reference of a canon idea that reaches the shape's project +# through a shared platform — any language, any project — under a stricter +# rule of its own, measured 2026-10-06 (#4991) on cross-project pairs: +# +# true pairs A py→go 0.728 (next 0.703) B py→go 0.715 (next 0.675) +# D kt→kt 0.816 (next 0.734) E go→go 0.610 (next 0.604) +# no reference N1 0.666 N2 0.623 N3 0.626 N4 0.715 N5 0.614 (best hit, +# none of them a family reference); unrelated hits to 0.774 +# +# No floor separates those bands — the false band reaches 0.715–0.774, a +# true pair sits at 0.610. RANK does: every true pair was the single best +# snippet the user can read. So a family reference is proposed only when it +# is the TOP hit over everything readable (not merely the first allowed one, +# as above) and clears _FAMILY_FLOOR: A, B, D proposed; E missed; no +# negative proposed. A miss is still not evidence — the judge can classify +# against the idea by idea_id. +_FAMILY_FLOOR = 0.70 def _semantic_priority(row) -> tuple: @@ -1640,7 +1716,9 @@ def _semantic_priority(row) -> tuple: # v5: the semantic arm's floor dropped from 0.8 to the write-path floor (#4208), # and the signature floor from 0.8 to 0.75 (#4306), so rows examined and # found nothing for must be read once more. -_PROPOSER_VERSION = 5 +# v6: the family arm (milestone 463 step 5) — a canon idea's reference on a +# shared platform, any language, as the top hit at _FAMILY_FLOOR. +_PROPOSER_VERSION = 6 # Signature resemblance floor, name blanked (difflib ratio) — and a length # floor, because `def NAME():` resembles `def NAME(x):` at 0.95 while saying # nothing; a family shape has parameters to resemble. @@ -1883,28 +1961,47 @@ def _substance(text: str) -> int: return len("".join((text or "").split())) +def pick_semantic( + hits: list[tuple[float, int]], allowed: set[int], family: set[int], *, floor: float, +) -> tuple[int, float, str] | None: + """Which ranked hit the semantic arm proposes, as (snippet_id, score, + basis). Pure; ``hits`` are (score, snippet_id) over everything the user + can read, best first. The TOP hit may be a family reference (basis + "family", at _FAMILY_FLOOR); any hit at ``floor`` may be an own-project + canon (basis "semantic").""" + for rank, (score, sid) in enumerate(hits): + if rank == 0 and sid in family and score >= _FAMILY_FLOOR: + return sid, round(float(score), 3), "family" + if sid in allowed and score >= floor: + return sid, round(float(score), 3), "semantic" + return None + + async def _semantic_canon( - user_id: int, body: str, allowed: set[int], -) -> tuple[int, float] | None: - """The canon this body MEANS, or None. None is "no proposal", never "not - the canon" — see the note above `_SEMANTIC_LIMIT`.""" + user_id: int, body: str, allowed: set[int], family: set[int] | None = None, +) -> tuple[int, float, str] | None: + """The canon this body MEANS, as (snippet_id, score, basis), or None. + None is "no proposal", never "not the canon" — see the note above + `_SEMANTIC_LIMIT`; the family arm's rule is the note above + `_FAMILY_FLOOR`.""" from scribe.services.embeddings import semantic_search_notes from scribe.services.plugin_context import ( WRITEPATH_DEFAULT_THRESHOLD, WRITEPATH_MIN_CODE_CHARS, concept_query, ) - if _substance(body) < WRITEPATH_MIN_CODE_CHARS or not allowed: + family = family or set() + if _substance(body) < WRITEPATH_MIN_CODE_CHARS or not (allowed or family): return None query = concept_query(body) or body hits = await semantic_search_notes( user_id, query, limit=_SEMANTIC_LIMIT, - threshold=WRITEPATH_DEFAULT_THRESHOLD, + threshold=min(WRITEPATH_DEFAULT_THRESHOLD, _FAMILY_FLOOR), note_type="snippet", scope="browse", ) - for score, note in hits: - if int(note.id) in allowed: - return int(note.id), round(float(score), 3) - return None + return pick_semantic( + [(float(score), int(note.id)) for score, note in hits], allowed, family, + floor=WRITEPATH_DEFAULT_THRESHOLD, + ) async def propose_for_repo( @@ -1933,6 +2030,16 @@ async def propose_for_repo( def semantic_allowed(path: str) -> set[int]: return {c.snippet_id for c in sym_canons if same_family(path, c.language)} + # The family arm: references of canon ideas on a platform this project + # shares, in any language (see _FAMILY_FLOOR). A project on no shared + # platform gets none, so nothing is proposed across to it. + try: + from scribe.services import family_adoption + + family_refs = await family_adoption.family_reference_ids(project_id) + except Exception: + logger.warning("family references unreadable for project %s", project_id, exc_info=True) + family_refs = set() now = datetime.now(timezone.utc) examined = proposed = checked = 0 async with async_session() as session: @@ -1998,13 +2105,13 @@ async def propose_for_repo( continue checked += 1 try: - found = await _semantic_canon(user_id, d[5], semantic_allowed(row.path)) + found = await _semantic_canon( + user_id, d[5], semantic_allowed(row.path), family_refs) except Exception: logger.warning("semantic proposal failed", exc_info=True) found = None if found: - row.proposed_snippet_id, row.proposal_score = found - row.proposal_basis = "semantic" + row.proposed_snippet_id, row.proposal_score, row.proposal_basis = found row.proposal_group = None proposed += 1 await session.commit() @@ -2210,11 +2317,13 @@ async def confirm_proposals( conds.append(CodeShape.proposal_basis == basis.strip()) now = datetime.now(timezone.utc) confirmed = 0 + touched: set[int] = set() async with async_session() as session: rows = (await session.execute(select(CodeShape).where(*conds))).scalars().all() for row in rows: if (row.proposal_score or 0.0) < min_score: continue + touched.add(row.proposed_snippet_id) await _judge( session, row, status="instance", snippet_id=row.proposed_snippet_id, by="agent", at=now, @@ -2225,7 +2334,11 @@ async def confirm_proposals( ) confirmed += 1 await session.commit() - return {"confirmed": confirmed} + out = {"confirmed": confirmed} + moved = await _sync_family(user_id, project_id, touched) + if moved: + out["family"] = moved + return out # --- the divergence readout (#2793): button B where button A is canon ------- diff --git a/tests/test_divergence_meaning_gate.py b/tests/test_divergence_meaning_gate.py index 864d00c7..adf37819 100644 --- a/tests/test_divergence_meaning_gate.py +++ b/tests/test_divergence_meaning_gate.py @@ -64,14 +64,14 @@ def _patch(mock: AsyncMock): async def test_a_hit_returns_the_canon() -> None: with _patch(_hits((0.88, CANON))): - assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.88) + assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.88, "semantic") async def test_an_allowed_canon_below_the_top_hit_still_wins() -> None: """The scan is over the whole result set, so a disallowed snippet ranking first does not hide an allowed one behind it.""" with _patch(_hits((0.95, OTHER), (0.83, CANON))): - assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.83) + assert await _semantic_canon(1, BODY, {CANON}) == (CANON, 0.83, "semantic") async def test_a_miss_is_none_and_nothing_more() -> None: diff --git a/tests/test_family_shapes.py b/tests/test_family_shapes.py new file mode 100644 index 00000000..baa2090b --- /dev/null +++ b/tests/test_family_shapes.py @@ -0,0 +1,142 @@ +"""The shape ledger judging against family ideas, without a database +(milestone 463 step 5). + +Pinned here: what a project's shapes say about an idea, when an answer +contradicts them, which reference a shape in a given language is judged +against, and the family arm's rule — measured on 2026-10-06 (#4991), and +replayed below as the table it was chosen from. The two ledgers moving +together against Postgres are in tests/test_integration_family_adoption.py. +""" +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from scribe.services.family_adoption import ( + _contradiction, path_speaks, shape_outcome, shapes_say, +) +from scribe.services.shape_ledger import _FAMILY_FLOOR, _semantic_canon, pick_semantic +from tests.helpers import tool_doc + + +def _shape(status: str, symbol: str = "f", reason: str = "", snippet_id: int = 7): + return SimpleNamespace(id=1, path=f"src/{symbol}.py", symbol=symbol, status=status, + reason=reason, snippet_id=snippet_id) + + +# --- what the shapes say --------------------------------------------------------- + +@pytest.mark.parametrize("statuses,expected", [ + (["instance"], "adopted"), + (["canonical"], "adopted"), + (["variant", "instance"], "adopted"), + (["variant"], "variant"), + ([], None), +]) +def test_shape_outcome(statuses, expected): + assert shape_outcome(statuses) == expected + + +def test_no_shape_is_silence_not_owed(): + assert shapes_say([]) is None + + +def test_an_adopted_reason_is_fixed_text_so_another_instance_logs_nothing(): + one = shapes_say([_shape("instance", "a")]) + two = shapes_say([_shape("instance", "a"), _shape("instance", "b")]) + assert one["reason"] == two["reason"] + assert two["evidence"] == ["src/a.py::a", "src/b.py::b"] + + +def test_a_variant_carries_the_shapes_why_and_only_variants_are_its_evidence(): + said = shapes_say([_shape("variant", "k", reason="a kiosk installs it")]) + assert said["outcome"] == "variant" and "a kiosk installs it" in said["reason"] + mixed = shapes_say([_shape("variant", "k", reason="x"), _shape("instance", "i")]) + assert mixed["outcome"] == "adopted" and mixed["evidence"] == ["src/i.py::i"] + + +# --- the two ledgers never disagree ----------------------------------------------- + +def test_an_answer_the_shapes_contradict_names_them_and_the_way_out(): + said = shapes_say([_shape("instance", "a")]) + refused = _contradiction("owed", said, idea_id=5) + assert "src/a.py::a" in refused and "classify_shapes" in refused and "#5" in refused + + +@pytest.mark.parametrize("outcome,said", [ + ("adopted", shapes_say([_shape("instance")])), + ("owed", None), + ("exempt", None), +]) +def test_an_agreeing_answer_or_silent_shapes_refuse_nothing(outcome, said): + assert _contradiction(outcome, said, idea_id=5) is None + + +# --- which reference a shape is judged against -------------------------------------- + +@pytest.mark.parametrize("path,language,expected", [ + ("tools/release.py", "python", True), + ("app/Updater.kt", "kotlin", True), + ("cmd/main.go", "go", True), + ("cmd/main.go", "kotlin", False), + ("web/App.vue", "vue", True), + ("README", "python", False), + ("x.py", "", False), +]) +def test_path_speaks(path, language, expected): + assert path_speaks(path, language) is expected + + +# --- the family arm, and the measurement it was chosen from ----------------------- + +OWN, REF, OTHER = 1, 2, 3 + + +@pytest.mark.parametrize("name,hits,expected", [ + # #4991's pairs, the reference as REF and the best other hit as OTHER. + ("A py->go", [(0.728, REF), (0.703, OTHER)], "family"), + ("B py->go", [(0.715, REF), (0.675, OTHER)], "family"), + ("D kt->kt", [(0.816, REF), (0.734, OTHER)], "family"), + ("E go->go", [(0.610, REF), (0.604, OTHER)], None), + # No reference exists: the best hit is unrelated, the reference trails. + ("N4", [(0.715, OTHER), (0.706, REF)], None), + ("N1", [(0.666, OTHER)], None), +]) +def test_the_family_arm_replays_its_measurement(name, hits, expected): + found = pick_semantic(hits, allowed=set(), family={REF}, floor=0.68) + assert (found[2] if found else None) == expected, name + + +def test_a_family_reference_must_be_the_top_hit_not_merely_the_first_allowed(): + assert pick_semantic([(0.80, OTHER), (0.79, REF)], set(), {REF}, floor=0.68) is None + + +def test_the_own_project_arm_still_looks_past_disallowed_hits(): + assert pick_semantic([(0.80, OTHER), (0.70, OWN)], {OWN}, {REF}, floor=0.68) == ( + OWN, 0.7, "semantic") + + +def test_the_family_floor_sits_between_the_bands_it_was_measured_on(): + assert 0.610 < _FAMILY_FLOOR <= 0.715 + + +async def test_a_project_on_a_shared_platform_is_searched_with_no_own_canon(): + """The own-project allowed set is often empty (the language gate); the + family references alone are reason to search.""" + body = "def throttle(key: str) -> bool:\n return attempts[key] < LIMIT and not locked(key)\n" + hit = SimpleNamespace(id=REF) + with patch("scribe.services.embeddings.semantic_search_notes", + AsyncMock(return_value=[(0.72, hit)])): + assert await _semantic_canon(1, body, set(), {REF}) == (REF, 0.72, "family") + + +# --- the agent-facing contract ----------------------------------------------------- + +def test_the_tools_teach_idea_id_and_the_family_basis(): + classify = tool_doc("scribe.mcp.tools.shapes", "classify_shapes") + assert "idea_id" in classify and "shapes contradict" in classify + assert "family" in tool_doc("scribe.mcp.tools.shapes", "list_shapes") + assert '"family"' in tool_doc("scribe.mcp.tools.shapes", "confirm_shape_proposals") + assert "classify_shapes" in tool_doc("scribe.mcp.tools.family", "assess_family_adoption") diff --git a/tests/test_integration_family_adoption.py b/tests/test_integration_family_adoption.py index bb75a100..8c911784 100644 --- a/tests/test_integration_family_adoption.py +++ b/tests/test_integration_family_adoption.py @@ -387,3 +387,191 @@ async def test_the_matrix_shows_every_member_and_who_has_not_been_asked(family): async def test_the_matrix_hides_what_the_caller_cannot_read(family): matrix = await adoption_svc.adoption_matrix(family["outsider"]) assert all(i["note_id"] != family["note"] for i in matrix["ideas"]) + + +# --- the shape ledger as evidence (step 5) ------------------------------------------- + +REPO = "git.example/android-two" +KOTLIN_BODY = ( + "fun installUpdate(context: Context, apk: File) {\n" + " val installer = context.packageManager.packageInstaller\n" + " val session = installer.openSession(installer.createSession(params()))\n" + " apk.inputStream().use { src -> session.openWrite(\"apk\", 0, apk.length()).use(src::copyTo) }\n" + "}\n" +) +B_SHAPES = [ + ("app/src/main/kotlin/Updater.kt", "sym", "selfUpdate"), + ("tools/release.py", "sym", "push_update"), +] + + +async def _reference(f, *, language: str = "kotlin", name: str = "installUpdate") -> int: + from scribe.services import snippets as snippets_svc + + snippet = await snippets_svc.create_snippet( + f["owner"], name=f"{name} ({language})", code=KOTLIN_BODY, language=language, + project_id=f["a"], + ) + return int(snippet.id) + + +@pytest_asyncio.fixture +async def shapes(family): + """Android two's ledger has two live shapes, and the idea has a Kotlin + reference in android one.""" + from scribe.services.shape_ledger import sync_repo_shapes + + await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main") + family["kotlin"] = await _reference(family) + await adoption_svc.set_references(family["owner"], family["note"], [family["kotlin"]]) + return family + + +async def _classify(f, project: str, symbol: str, status: str, **item): + from scribe.services import shape_ledger + + path = next(p for p, _, s in B_SHAPES if s == symbol) + return await shape_ledger.classify_shapes( + f["owner"], f[project], [{"path": path, "symbol": symbol, "status": status, **item}]) + + +async def test_a_shape_classified_against_the_idea_answers_it_adopted(shapes): + """Across languages: a Python shape is an instance of an idea whose only + reference is Kotlin, and the project's answer moves with it.""" + out = await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + assert out["classified"] == 1 + assert [m["status"] for m in out["family"]] == ["adopted"] + row = await _row(shapes, "b") + assert (row.status, row.decided_via, row.canon_version) == ("adopted", "system", 1) + [decision] = await _decisions(shapes, "b") + assert decision.evidence["source"] == adoption_svc.SHAPE_SOURCE + assert decision.evidence["evidence"] == ["tools/release.py::push_update"] + assert decision.evidence["shapes"][0]["snippet_id"] == shapes["kotlin"] + # The matrix shows the code that answers the cell. + matrix = await adoption_svc.adoption_matrix(shapes["owner"], project_id=shapes["b"]) + [cell] = [c for c in matrix["cells"] if c["idea_id"] == shapes["note"]] + assert cell["shapes"] == [ + {"path": "tools/release.py", "symbol": "push_update", "status": "instance"}] + # A second instance agrees with the standing answer: nothing more is logged. + await _classify(shapes, "b", "selfUpdate", "instance", idea_id=shapes["note"]) + assert len(await _decisions(shapes, "b")) == 1 + + +async def test_idea_id_prefers_the_reference_in_the_shapes_language(shapes): + from scribe.services.shape_ledger import live_rows + + python_ref = await _reference(shapes, language="python", name="install_update") + await adoption_svc.set_references(shapes["owner"], shapes["note"], + [shapes["kotlin"], python_ref]) + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + await _classify(shapes, "b", "selfUpdate", "instance", idea_id=shapes["note"]) + by_symbol = {r.symbol: r.snippet_id for r in await live_rows(shapes["b"])} + assert by_symbol == {"push_update": python_ref, "selfUpdate": shapes["kotlin"]} + + +async def test_an_idea_with_no_reference_is_refused_and_nothing_applies(family): + from scribe.services.shape_ledger import live_rows, sync_repo_shapes + + await sync_repo_shapes(family["b"], REPO, B_SHAPES, seen_marker="main") + with pytest.raises(ValueError, match="no reference implementation"): + await _classify(family, "b", "push_update", "instance", idea_id=family["note"]) + assert {r.status for r in await live_rows(family["b"])} == {"unclassified"} + + +async def test_a_variant_shape_answers_variant_and_withdrawing_it_unassesses(shapes): + await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"], + reason="installs through the device-owner API: it is a kiosk") + row = await _row(shapes, "b") + assert row.status == "variant" and "device-owner API" in row.reason + out = await _classify(shapes, "b", "selfUpdate", "unclassified") + assert [(m["idea_id"], m["status"]) for m in out["family"]] == [ + (shapes["note"], "unassessed")] + row = await _row(shapes, "b") + assert (row.status, row.canon_version, row.decided_via) == ("unassessed", None, None) + assert (await _decisions(shapes, "b"))[-1].evidence["source"] == adoption_svc.SHAPE_SOURCE + + +async def test_the_shapes_close_an_owed_task_as_done(shapes): + owed = await _assess(shapes, "b", "owed", reason="no in-place update yet") + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + assert (await _task(owed["owed_task"]["id"])).status == "done" + + +async def test_an_answer_the_shapes_contradict_is_refused(shapes): + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + with pytest.raises(ValueError, match="never disagree"): + await _assess(shapes, "b", "owed", reason="the agent thinks otherwise") + # The agreeing answer is not refused. + await _assess(shapes, "b", "adopted", reason="release.py does it", + evidence=["tools/release.py"]) + + +async def test_an_undo_that_would_contradict_the_shapes_is_refused(shapes): + await _assess(shapes, "b", "owed", reason="not yet") + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + latest = (await _decisions(shapes, "b"))[-1] + with pytest.raises(ValueError, match="never disagree"): + await family_svc.undo(shapes["owner"], latest.id, reason="go back") + + +async def test_an_answer_given_on_other_evidence_survives_the_shapes_leaving(shapes): + """No shape is not the same as owed: withdrawing a shape only withdraws + an answer the shape ledger gave.""" + await _assess(shapes, "b", "adopted", reason="CI proves it", + evidence=["ci run 12"]) + await _classify(shapes, "b", "push_update", "instance", idea_id=shapes["note"]) + out = await _classify(shapes, "b", "push_update", "unclassified") + assert "family" not in out + assert (await _row(shapes, "b")).status == "adopted" + + +async def test_nothing_is_answered_or_proposed_across_projects_sharing_no_platform(shapes): + """The Go service shares no platform with the idea: its shape judged + against the reference is plain canon use, and the proposer never offers + the reference to it.""" + from scribe.services import shape_ledger + + await shape_ledger.sync_repo_shapes(shapes["c"], "git.example/go", [ + ("cmd/update.go", "sym", "PushUpdate")], seen_marker="main") + out = await shape_ledger.classify_shapes(shapes["owner"], shapes["c"], [ + {"path": "cmd/update.go", "symbol": "PushUpdate", "status": "instance", + "snippet_id": shapes["kotlin"]}]) + assert out["classified"] == 1 and "family" not in out + async with async_session() as s: + assert (await s.execute(select(FamilyAdoption).where( + FamilyAdoption.project_id == shapes["c"]))).first() is None + assert await adoption_svc.family_reference_ids(shapes["c"]) == set() + assert await adoption_svc.family_reference_ids(shapes["b"]) == {shapes["kotlin"]} + + +async def test_the_proposer_offers_the_family_reference_as_the_top_hit(shapes): + from scribe.services import shape_ledger + + body = ("def push_update(apk_path: str) -> None:\n" + " session = installer.open_session(installer.create_session())\n" + " session.write_stream('apk', open(apk_path, 'rb'))\n" + " session.commit()\n") + defs = [("tools/release.py", "sym", "push_update", "def push_update(apk_path: str) -> None:", + "sha-push", body)] + ref = type("Hit", (), {"id": shapes["kotlin"]})() + with patch("scribe.services.embeddings.semantic_search_notes", + AsyncMock(return_value=[(0.73, ref)])): + await shape_ledger.propose_for_repo(shapes["owner"], shapes["b"], REPO, defs) + row = next(r for r in await shape_ledger.live_rows(shapes["b"]) if r.symbol == "push_update") + assert (row.proposed_snippet_id, row.proposal_basis) == (shapes["kotlin"], "family") + # Confirming it answers the idea. + out = await shape_ledger.confirm_proposals(shapes["owner"], shapes["b"], basis="family") + assert out["confirmed"] == 1 and out["family"][0]["status"] == "adopted" + + +async def test_a_conflict_brings_the_losing_sides_variant_shapes_back_to_the_todo(shapes): + from scribe.services.shape_ledger import live_rows + + await _classify(shapes, "b", "selfUpdate", "variant", idea_id=shapes["note"], + reason="signs in CI with a throwaway key") + out = await _resolve(shapes, "covers_failure") + assert out["shapes_rejudged"] == {str(shapes["b"]): 1} + b = await _row(shapes, "b") + assert b.status == "owed" + row = next(r for r in await live_rows(shapes["b"]) if r.symbol == "selfUpdate") + assert row.status == "unclassified" -- 2.54.0 From 07e21c7ff9f7b4b5d027c1bef62c2035cd82685f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 13:27:39 -0400 Subject: [PATCH 09/10] feat(family): family canon reaches the session - entry readout, retrieval reach, skill, report cue (milestone 463 step 6, #4992) - enter_project carries a `family` key, but only when the project has something to answer: counts of unassessed, owed and to-recheck answers, each with the list_family_adoptions call that lists it. It shows on every entry, never by platform touch: entry is when work is chosen, and an unanswered idea is otherwise invisible. - Retrieval: a widened project search (include_global_kinds) now also reaches the canon ideas on the project's platforms. It also reaches their references in the project's languages, or all of them when none matches. An off-platform project gets none, and the plain project filter (the duplicate gate) is unchanged. - Closing a task returns `family_owed`, the owed answers filed while it was open, and the report cue asks for them to be named. - New plugin skill family-canon (moment work.record) covers when to evaluate a promotion, answering in order, what counts as a reason, the precedent reflex and the conflict order. _INSTRUCTIONS, create_note, create_snippet, classify_shapes and reporting-back point at it. The plugin version is minted. Co-Authored-By: Claude Opus 5.5 --- plugin/.claude-plugin/plugin.json | 4 +- plugin/hooks/scribe_static_context.md | 2 +- plugin/skills/family-canon/SKILL.md | 93 ++++++++++++++ plugin/skills/reporting-back/SKILL.md | 2 + src/scribe/mcp/server.py | 15 +-- src/scribe/mcp/tools/notes.py | 5 +- src/scribe/mcp/tools/projects.py | 26 +++- src/scribe/mcp/tools/shapes.py | 2 +- src/scribe/mcp/tools/snippets.py | 4 +- src/scribe/mcp/tools/tasks.py | 9 +- src/scribe/services/embeddings.py | 16 +++ src/scribe/services/family_adoption.py | 131 +++++++++++++++++++ src/scribe/services/moment_actions.py | 1 + tests/test_family_delivery.py | 99 ++++++++++++++ tests/test_guidance_ownership.py | 9 ++ tests/test_integration_family_reach.py | 170 +++++++++++++++++++++++++ tests/test_mcp_tool_projects.py | 41 ++++++ tests/test_mcp_tool_report_back_cue.py | 25 +++- tests/test_milestone_summary_brief.py | 2 + 19 files changed, 637 insertions(+), 19 deletions(-) create mode 100644 plugin/skills/family-canon/SKILL.md create mode 100644 tests/test_family_delivery.py create mode 100644 tests/test_integration_family_reach.py diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index bc96d731..665e6325 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "scribe", - "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).", - "version": "2026.10.05.2219", + "description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting, family-canon), and syncs your saved Scribe Processes as skills (/scribe:sync).", + "version": "2026.10.06.1726", "author": { "name": "Bryan Van Deusen" }, diff --git a/plugin/hooks/scribe_static_context.md b/plugin/hooks/scribe_static_context.md index 64550e14..d602efa0 100644 --- a/plugin/hooks/scribe_static_context.md +++ b/plugin/hooks/scribe_static_context.md @@ -6,7 +6,7 @@ once, in the **`using-scribe`** skill: reach for it at the start of the session and whenever you are unsure what Scribe expects. Each tool's contract is in its description, and the process skills (writing-plans, reporting-back, reusing-code, systematic-debugging, verification, brainstorming, -shape-accounting) carry their arcs. +shape-accounting, family-canon) carry their arcs. What only Claude Code needs said: diff --git a/plugin/skills/family-canon/SKILL.md b/plugin/skills/family-canon/SKILL.md new file mode 100644 index 00000000..ff883481 --- /dev/null +++ b/plugin/skills/family-canon/SKILL.md @@ -0,0 +1,93 @@ +--- +name: family-canon +description: Use when family canon reaches the session — a `family_hint` on a create (a record may be a pattern every project on a platform will need), a `family` key from enter_project (ideas this project has not answered, owes, or must recheck), a `family_owed` list when a task closes, or the operator asks whether a pattern is shared between projects. Triggers on "family", "shared between projects", "promote", "canon idea", "owed", "adopt", "every Android app", "every project on". +metadata: + moments: work.record +--- + +# Family canon — good ideas spread by criteria, not by approval + +Some patterns are not one app's: every project on a platform meets the same +problem (an Android app that ships its own APK, a Go service behind a +reverse proxy). Family canon is how such an idea is recorded once, reaches +every project on that platform, and gets an answer from each. Nobody +approves anything. **You decide, against written criteria, and the decision +log keeps you consistent with the last decision like it.** The tools quote +the criteria, the outcomes and the conflict order in full; this skill is when +to reach for them and what a good answer looks like. + +The shape is the point, not identical code: the same idea in Kotlin and in +Go is one idea with two reference implementations. + +## When an idea is evaluated for promotion + +A `family_hint` on a create_note / create_snippet response means a trigger +opened an evaluation: the record cites another project's record as its +source, or it repeats a record in a project on a shared platform. The source +is now a `candidate`. Evaluate it in the same turn, while you know why you +wrote what you wrote: + +1. `get_family_idea(id)` — the candidate, its decisions, and the precedents + (decisions on the ideas nearest it). +2. Judge the three criteria from `promote_family_idea`'s description. Each + needs support you can name; **one criterion with no support vetoes it**. +3. Promote it, or leave it a candidate with the reason. A held candidate is + precedent too, so the reason is the record — never skip writing it. + +A milestone closing on a platform project hints without recording anything: +ask whether what it built is something every project on the platform will +face, and if so write the note that carries the idea. + +## Answering an idea in a project + +enter_project's `family` key counts what this project owes the family: +`unassessed`, `owed`, `needs_recheck`, each with the call that lists them. +Answer an idea **when your work reaches its area**, not as a chore list at +session start — the count is there so it is never invisible, not so it +preempts the operator's task. + +- `get_family_adoption(project_id, idea_id)` first: the idea, the project's + current answer, the precedents, and the reference implementations with the + project's languages. +- Judge the outcomes **in the order `assess_family_adoption` lists them**, + stopping at the first that holds. Every outcome needs a reason; `adopted` + needs evidence naming where. +- **When the code is in the shape ledger, answer there instead:** + `classify_shapes` the shape with `idea_id=` as an `instance` or a + `variant`, and the adoption answer moves with it. An answer the shapes + contradict is refused — fix the shapes, not the answer. +- `owed` files a task in THAT project and stops. Nothing here edits another + repository; the project's own session picks the task up. + +### What counts as a reason + +A reason names a **fact about this project**: "the app is installed by a +managed store, never sideloaded", "the service has no user accounts". A +preference, a taste, or "we already did it another way" is not a reason — +that is `owed`, or, if this project's way is better rather than different, a +conflict. Write the reason so a session in another project can tell whether +the same fact holds there; that is how it becomes precedent. + +### The precedent reflex + +Before answering, read what was answered before: the same idea in other +projects, and this project's answers to the nearest ideas. Answer +consistently unless this project differs in a way you can name — and then +the difference IS the reason. The engine records the precedents it found +whether or not you cite them, so an inconsistent answer is visible later. + +## When two projects disagree + +Two projects solving one idea differently, each sure of its way, is a +conflict — settle it with `resolve_family_conflict`, which states the order. +The first ground that applies wins, and **every ground above it must be said +not to apply** — you cannot skip to the one you like. The losing side's +reasoning is folded into the idea's note (as a trap, an alternative, or the +branch for its condition) and never dropped; the version moves, so every +other project's answer reads as needing a recheck. + +## Reporting it + +A `family_owed` list on a closing task is work you filed into other projects +while it was open: name each one in the report, by project and idea — it is +waiting there now. diff --git a/plugin/skills/reporting-back/SKILL.md b/plugin/skills/reporting-back/SKILL.md index a8766c8e..7dbe9eb2 100644 --- a/plugin/skills/reporting-back/SKILL.md +++ b/plugin/skills/reporting-back/SKILL.md @@ -207,6 +207,8 @@ Notes on each section: the task alone is enough. - **What now works** — outcomes the operator would notice: "You can now…", "X no longer…". The files and steps behind them belong in the task's log. + A `family_owed` list on the closing response is work you filed into other + projects: name each by project and idea (the family-canon skill). - **How / why** — only the decisions worth knowing, plus **how it was verified**. If something could not be verified, say what and why here rather than letting it read as passed. diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index f405d829..43d3637f 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -43,13 +43,12 @@ from quart import Quart # decision #4027 and the notes it supersedes. _INSTRUCTIONS = """ Scribe is the operator's system of record, and yours: recall before acting, -record as you go, keep one copy here, not in local memory files. Each -practice is stated in full in the using-scribe skill and each tool's -description. +record as you go, keep one copy here, not in local memory. Each +practice is stated in full in a skill or a tool's description. -- Start with enter_project(id): the project, open work, Systems and design - system. An `inception` key: ask what it inherits, then - decide_project_inception. +- Start with enter_project(id): the project, open work, Systems, design + system. `inception`: ask what it inherits, then decide_project_inception. + `family`: shared ideas to answer (family-canon skill). - Rules are not preloaded; one arrives when your work matches it or reaches a moment it is mounted on (list_moments; mount rules about WHEN). Before a consequential act, what_might_apply("what you are about to do"); @@ -65,8 +64,8 @@ description. (search(content_type="milestone")) before start_planning. - IDs exist only once a create returns them; records citing each other go through create_records, writing {{ref:N}} for the Nth. -- In UI work the project's design system binds: resolve_design_system before - hand-writing a value. +- In UI work the design system binds: resolve_design_system before + writing a value. Creates are duplicate-gated: a near-match returns the existing id to update. shared:true records are another user's suggestion, not settled practice. diff --git a/src/scribe/mcp/tools/notes.py b/src/scribe/mcp/tools/notes.py index 94334c66..45e57d15 100644 --- a/src/scribe/mcp/tools/notes.py +++ b/src/scribe/mcp/tools/notes.py @@ -204,7 +204,10 @@ async def create_note( "existing_id": ..., "message": ...} and nothing is created. A tagged record shows its `systems`; created untagged in a project, the response carries the `systems_hint` question instead — answer it: tag the record, - create the missing System, or deliberately leave it untagged. + create the missing System, or deliberately leave it untagged. A + `family_hint` means the note may carry an idea every project on a shared + platform will need, and an evaluation was opened — the family-canon skill + says how to judge it. """ uid = current_user_id() await refuse_guessed_ids(title, body) diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index 087feecf..a06e72de 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -16,10 +16,13 @@ keeps working. """ from __future__ import annotations +import logging + from scribe.mcp._context import current_user_id from scribe.mcp.tools import systems as systems_tools from scribe.services import coverage as coverage_svc from scribe.services import design_systems as design_systems_svc +from scribe.services import family_adoption as family_adoption_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 @@ -31,6 +34,8 @@ from scribe.services import trash as trash_svc from scribe.services.background import spawn from scribe.services.note_usage import record_surfaced +logger = logging.getLogger(__name__) + async def list_projects() -> dict: """List all Scribe projects for the current user. @@ -70,8 +75,8 @@ async def enter_project(project_id: int) -> dict: Returns a dict with keys: project, milestone_summary, open_tasks, systems, design_system, project_rules, pattern_coverage — plus unplanned_milestones, milestone_summary_omitted, - unplanned_milestones_omitted, inception and systems_bootstrap, each - present only when it applies (see below). + unplanned_milestones_omitted, family, inception and systems_bootstrap, + each present only when it applies (see below). `project` is id, title, status and the full goal. get_project has the whole record. @@ -132,6 +137,14 @@ async def enter_project(project_id: int) -> dict: family ideas reach this project. An empty list on a project that plainly ships something is worth correcting with set_project_platforms. + `family` (milestone 463) appears ONLY when this project has family canon + to answer: how many canon ideas on its platforms are `unassessed`, how + many it `owed`s, and how many answers were given against an older canon + version (`needs_recheck`) — each with the list_family_adoptions call that + lists them. Answer an unassessed idea when your work reaches its area + (get_family_adoption, then assess_family_adoption, or classify the code + against it); the family-canon skill has the order. + `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, platforms), what to ask the @@ -299,6 +312,15 @@ async def enter_project(project_id: int) -> dict: ) if systems_bootstrap: out["systems_bootstrap"] = systems_bootstrap + # Family canon (milestone 463 step 6): counts only, attached when one is + # non-zero. A readout must never fail the handshake it rides on. + try: + family = await family_adoption_svc.family_readout(uid, project_id) + except Exception: + logger.warning("family readout failed for project %s", project_id, exc_info=True) + family = None + if family: + out["family"] = family if inception_ask: out["inception"] = inception_ask return out diff --git a/src/scribe/mcp/tools/shapes.py b/src/scribe/mcp/tools/shapes.py index f2bd4c77..4c8ea4f2 100644 --- a/src/scribe/mcp/tools/shapes.py +++ b/src/scribe/mcp/tools/shapes.py @@ -71,7 +71,7 @@ async def classify_shapes( withdrawing the shapes that gave an answer returns it to `unassessed`. The adoption ledger is moved for you — `family` in the result lists the answers that moved — and assess_family_adoption refuses an answer the - shapes contradict. + shapes contradict. The family-canon skill has when to answer an idea. All-or-nothing: a structural error, a missing snippet target, an unbound repo, or no write access applies NOTHING. Returns {"classified": N, diff --git a/src/scribe/mcp/tools/snippets.py b/src/scribe/mcp/tools/snippets.py index 27015be1..a19f6b4c 100644 --- a/src/scribe/mcp/tools/snippets.py +++ b/src/scribe/mcp/tools/snippets.py @@ -178,7 +178,9 @@ async def create_snippet( rather than forcing a second copy with force=true. A tagged record shows its `systems`; created untagged in a project, the response carries the `systems_hint` question instead — answer it: tag, create the missing - System, or deliberately skip. + System, or deliberately skip. A `family_hint` means the shape may be one + every project on a shared platform will need, and an evaluation was + opened — the family-canon skill says how to judge it. WHAT THE GATE MATCHES ON. Exact identity first — an existing snippet at the same repo · path · symbol, or holding byte-identical code. Those are certain, diff --git a/src/scribe/mcp/tools/tasks.py b/src/scribe/mcp/tools/tasks.py index 247b93a1..3b4851e6 100644 --- a/src/scribe/mcp/tools/tasks.py +++ b/src/scribe/mcp/tools/tasks.py @@ -29,6 +29,7 @@ from scribe.mcp.tools import systems as systems_tools from scribe.services import access as access_svc from scribe.services import dedup as dedup_svc from scribe.services import family as family_svc +from scribe.services import family_adoption as family_adoption_svc from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc # Imported by NAME, not reached through notes_svc: minted_kind is pure @@ -422,7 +423,10 @@ async def update_task( by their `when_to_apply`, so a preference whose trigger is writing the report after finishing a task is the one that arrives here. Where one differs from the default shape, the preference is what the operator - asked for. + asked for. When family adoptions were answered `owed` while the task was + open, they come back as `family_owed` ({idea_id, idea_title, project_id, + project_title, owed_task_id}): name each in the report — it is work + filed into that project. """ uid = current_user_id() fields: dict = {} @@ -473,6 +477,9 @@ async def update_task( if prefs: data["reply_preferences"] = prefs data["report_back"] = REPORT_BACK_CUE + " " + REPLY_PREFERENCES_CUE + # Owed family adoptions filed while the task was open (milestone 463 + # step 6) — work now waiting in some project, which the report names. + await family_adoption_svc.attach_owed_adoptions(uid, data, note) return await moment_delivery.attach_moment_rules( uid, "update_task", {"status": status, "project_id": project_id}, data, ) diff --git a/src/scribe/services/embeddings.py b/src/scribe/services/embeddings.py index 5be290cb..3b7e4af7 100644 --- a/src/scribe/services/embeddings.py +++ b/src/scribe/services/embeddings.py @@ -1047,6 +1047,22 @@ async def semantic_search_notes( in_project = or_( in_project, Note.note_type.in_(GLOBAL_NOTE_TYPES) ) + # Family canon (milestone 463 step 6) crosses the project + # line the same way a lesson does, but only to a project + # on one of the idea's platforms: the canon ideas reaching + # it, and their references in its languages. Membership + # is decided here, readability by the visibility clause + # above. Read on its own session, so a failure narrows + # the search and cannot abort this one's transaction. + try: + from scribe.services.family_adoption import family_reach_ids + + reach = await family_reach_ids(project_id) + except Exception: + logger.debug("family reach unavailable", exc_info=True) + reach = set() + if reach: + in_project = or_(in_project, Note.id.in_(sorted(reach))) stmt = stmt.where(in_project) # Narrow to records tagged to one System (subsystem/area). An # association filter, not a ranking signal — membership in the diff --git a/src/scribe/services/family_adoption.py b/src/scribe/services/family_adoption.py index 6d3f9103..e4313fb3 100644 --- a/src/scribe/services/family_adoption.py +++ b/src/scribe/services/family_adoption.py @@ -1214,3 +1214,134 @@ async def _shapes_follow(user_id: int, project_id: int, idea_id: int, outcome: s for r in rows ], via="agent") return int(result.get("classified") or 0) + + +# --- delivery (milestone 463 step 6) ------------------------------------------------- +# +# The ledger reaches a session three ways: a count on entering a project, the +# ideas themselves by retrieval, and the owed answers filed while a task was +# open, handed back when it closes. + +def family_line(project_id: int, cells: list[dict]) -> dict | None: + """The `family` line enter_project carries: what this project has not + answered, owes, or answered against an older canon version. None when + all three are zero — the key is attached only when it asks something. + + Shown on EVERY entry (the step settled this), not only when a session + touches a platform: entering is when the agent chooses what to work on, + an owed task already appears in open_tasks, and an unanswered idea has + nowhere else to be seen. Counts only, each with the call that lists it.""" + unassessed = sum(1 for c in cells if c["in_scope"] and c["status"] == "unassessed") + owed = sum(1 for c in cells if c["status"] == "owed") + recheck = sum(1 for c in cells if c["needs_recheck"]) + if not (unassessed or owed or recheck): + return None + parts, calls = [], {} + if unassessed: + parts.append(f"{unassessed} unassessed") + calls["unassessed"] = f'list_family_adoptions(project_id={project_id}, status="unassessed")' + if owed: + parts.append(f"{owed} owed") + calls["owed"] = f'list_family_adoptions(project_id={project_id}, status="owed")' + if recheck: + parts.append(f"{recheck} to recheck") + calls["needs_recheck"] = f"list_family_adoptions(project_id={project_id}, needs_recheck=true)" + return { + "line": "family canon on this project's platforms: " + " · ".join(parts), + "unassessed": unassessed, "owed": owed, "needs_recheck": recheck, + "calls": calls, + "answer_with": ("get_family_adoption(project_id, idea_id) reads one with its " + "precedents; assess_family_adoption answers it — or classify_shapes " + "the code against it (idea_id=…). The family-canon skill has the order."), + } + + +async def family_readout(user_id: int, project_id: int) -> dict | None: + matrix = await adoption_matrix(user_id, project_id=project_id) + return family_line(project_id, matrix["cells"]) + + +async def family_reach_ids(project_id: int, session=None) -> set[int]: + """The family records a project's retrieval reaches beyond its own: every + canon idea on a platform the project is a member of, and each idea's + reference implementations in the project's languages — all of them when + none is in those languages, so a cross-language idea still brings its + reference. Readability is the search's own scope, not decided here.""" + if session is None: + async with async_session() as own: + return await family_reach_ids(project_id, own) + ideas = set((await session.execute( + select(FamilyIdea.note_id) + .join(FamilyIdeaPlatform, FamilyIdeaPlatform.note_id == FamilyIdea.note_id) + .join(ProjectPlatform, ProjectPlatform.platform_id == FamilyIdeaPlatform.platform_id) + .where( + FamilyIdea.status == "canon", + ProjectPlatform.project_id == project_id, + ProjectPlatform.state.in_(family_svc.MEMBER_STATES), + ) + )).scalars().all()) + if not ideas: + return set() + langs = set(await _project_languages(session, project_id)) + refs: dict[int, list[tuple[int, str]]] = {} + for iid, nid, lang in (await session.execute( + select(FamilyIdeaReference.idea_id, Note.id, func.lower(Note.data["language"].astext)) + .join(Note, Note.id == FamilyIdeaReference.snippet_id) + .where(FamilyIdeaReference.idea_id.in_(ideas), Note.deleted_at.is_(None)) + )).all(): + refs.setdefault(iid, []).append((nid, lang or "")) + reach = set(ideas) + for found in refs.values(): + local = [nid for nid, lang in found if lang in langs] + reach.update(local or [nid for nid, _ in found]) + return reach + + +async def owed_since(user_id: int, since) -> list[dict]: + """The owed answers this user recorded since ``since`` that are still + owed — work filed into a project (often another one) that the report + closing this task should name.""" + if since is None: + return [] + async with async_session() as session: + rows = (await session.execute( + select(FamilyAdoption, Note.title, Project.title) + .join(FamilyDecision, (FamilyDecision.idea_id == FamilyAdoption.idea_id) + & (FamilyDecision.project_id == FamilyAdoption.project_id)) + .join(Note, Note.id == FamilyAdoption.idea_id) + .join(Project, Project.id == FamilyAdoption.project_id) + .where( + FamilyDecision.user_id == user_id, + FamilyDecision.created_at >= since, + FamilyDecision.after["status"].astext == "owed", + FamilyAdoption.status == "owed", + ) + .distinct() + .order_by(Project.title, Note.title) + )).all() + return [ + {"idea_id": row.idea_id, "idea_title": idea_title, + "project_id": row.project_id, "project_title": project_title, + "owed_task_id": row.owed_task_id} + for row, idea_title, project_title in rows + if await access.can_read_project(user_id, row.project_id) + ] + + +OWED_CUE = ("Owed family adoptions were filed while this task was open (`family_owed`) — " + "name each in the report: it is work now waiting in that project.") + + +async def attach_owed_adoptions(user_id: int, data: dict, note) -> None: + """Ride the owed answers filed since the task started on its closing + response — fail-open: a decoration never breaks the close it rides on.""" + try: + owed = await owed_since(user_id, getattr(note, "started_at", None) + or getattr(note, "created_at", None)) + if owed: + data["family_owed"] = owed + if "report_back" in data: + data["report_back"] = f"{data['report_back']} {OWED_CUE}" + except Exception: + logger.warning("owed adoptions unreadable for task %s", getattr(note, "id", None), + exc_info=True) diff --git a/src/scribe/services/moment_actions.py b/src/scribe/services/moment_actions.py index 179a450c..58d6e728 100644 --- a/src/scribe/services/moment_actions.py +++ b/src/scribe/services/moment_actions.py @@ -80,6 +80,7 @@ BUNDLED_SKILL_MOMENTS: dict[str, tuple[str, ...]] = { "verification": ("work.verify",), "shape-accounting": ("work.record",), "reporting-back": ("reply.report",), + "family-canon": ("work.record",), } # A stored process arrives as the skill `scribe-proc-` diff --git a/tests/test_family_delivery.py b/tests/test_family_delivery.py new file mode 100644 index 00000000..91faf078 --- /dev/null +++ b/tests/test_family_delivery.py @@ -0,0 +1,99 @@ +"""How family canon reaches a session (milestone 463 step 6), without a +database: the line enter_project carries, the skill and its triggers, and the +pointers every other surface gives to it. Retrieval reach, the readout's +counts and the owed answers against Postgres are in +tests/test_integration_family_reach.py. +""" +from __future__ import annotations + +import pathlib +import re + +from scribe.services import moment_actions +from scribe.services.family_adoption import family_line +from tests.helpers import skill_text, tool_doc + +ROOT = pathlib.Path(__file__).resolve().parents[1] + + +def _cell(status="unassessed", *, in_scope=True, needs_recheck=False): + return {"status": status, "in_scope": in_scope, "needs_recheck": needs_recheck} + + +# --- the enter_project line ---------------------------------------------------------- + +def test_nothing_to_answer_is_no_line(): + assert family_line(5, []) is None + assert family_line(5, [_cell("adopted"), _cell("exempt")]) is None + + +def test_the_line_counts_each_ask_and_names_the_call_that_lists_it(): + line = family_line(5, [ + _cell(), _cell(), _cell("owed"), _cell("adopted", needs_recheck=True), + ]) + assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (2, 1, 1) + assert line["line"].endswith("2 unassessed · 1 owed · 1 to recheck") + assert line["calls"] == { + "unassessed": 'list_family_adoptions(project_id=5, status="unassessed")', + "owed": 'list_family_adoptions(project_id=5, status="owed")', + "needs_recheck": "list_family_adoptions(project_id=5, needs_recheck=true)", + } + assert "family-canon" in line["answer_with"] + + +def test_a_count_of_zero_names_no_call(): + line = family_line(5, [_cell("owed")]) + assert set(line["calls"]) == {"owed"} and "unassessed" not in line["line"] + + +def test_an_answer_kept_as_history_off_the_platform_is_not_asked_again(): + """A row whose project left the idea's platforms stays as history; an + unassessed one there is not something this project is asked for.""" + assert family_line(5, [_cell(in_scope=False)]) is None + + +# --- the skill ---------------------------------------------------------------------- + +def _frontmatter(name: str) -> str: + text = (ROOT / "plugin" / "skills" / name / "SKILL.md").read_text() + return re.match(r"\A---\n(.*?)\n---\n", text, re.S).group(1) + + +def test_the_family_canon_skill_ships_and_triggers_on_what_the_server_sends(): + """Its description is what makes a session load it, so it names every + key the server hands back about family canon.""" + front = _frontmatter("family-canon") + assert "name: family-canon" in front + for trigger in ("family_hint", "`family`", "family_owed", "enter_project"): + assert trigger in front, trigger + assert moment_actions.BUNDLED_SKILL_MOMENTS["family-canon"] == ("work.record",) + + +def test_the_skill_carries_the_reflexes_and_defers_the_lists_to_the_tools(): + text = " ".join(skill_text("family-canon").split()) + for phrase in ("one criterion with no support vetoes it", + "in the order `assess_family_adoption` lists them", + "every ground above it must be said not to apply", + "What counts as a reason", "The precedent reflex"): + assert phrase in text, phrase + + +def test_the_plugin_names_the_skill_it_ships(): + manifest = (ROOT / "plugin" / ".claude-plugin" / "plugin.json").read_text() + static = (ROOT / "plugin" / "hooks" / "scribe_static_context.md").read_text() + assert "family-canon" in manifest and "family-canon" in " ".join(static.split()) + + +# --- the pointers --------------------------------------------------------------------- + +def test_every_surface_that_meets_family_canon_points_at_the_skill(): + server = (ROOT / "src" / "scribe" / "mcp" / "server.py").read_text() + index = re.search(r'_INSTRUCTIONS = """(.*?)"""', server, re.S).group(1) + assert "family-canon" in index and "`family`" in index + for module, tool in (("notes", "create_note"), ("snippets", "create_snippet"), + ("shapes", "classify_shapes")): + assert "family-canon" in tool_doc(f"scribe.mcp.tools.{module}", tool), tool + assert "family_hint" in tool_doc("scribe.mcp.tools.notes", "create_note") + assert "`family`" in tool_doc("scribe.mcp.tools.projects", "enter_project") + assert "family_owed" in tool_doc("scribe.mcp.tools.tasks", "update_task") + assert "family_owed" in skill_text("reporting-back") diff --git a/tests/test_guidance_ownership.py b/tests/test_guidance_ownership.py index 897ddf19..7efe543d 100644 --- a/tests/test_guidance_ownership.py +++ b/tests/test_guidance_ownership.py @@ -270,6 +270,15 @@ TOPICS: tuple[Topic, ...] = ( Topic("a record you only cite still gets read", "skill:reporting-back", ("next_step", "only mention"), "a record you only mention is a record to read"), + # Milestone 463 step 6: family canon — when an idea is evaluated, how a + # project answers one, what a reason is, and the conflict order's reflex. + # The criteria, outcomes and grounds themselves are product text on the + # tools that enforce them; the skill owns when and how. + Topic("family canon: evaluate on a hint, answer in order, a reason is a fact", + "skill:family-canon", + ("family_hint", "get_family_adoption", "resolve_family_conflict", "precedent"), + "write the reason so a session in another project can tell whether the same fact holds there", + index=("family-canon",)), # ── per-tool contracts and in-band behaviour — owned by the server ── Topic("closing a task cues the report", "docstrings", ("report_back",), "reporting this to the operator?"), # The agent is the judge (#4208). Two topics, not one, because they fire diff --git a/tests/test_integration_family_reach.py b/tests/test_integration_family_reach.py new file mode 100644 index 00000000..b7f0416d --- /dev/null +++ b/tests/test_integration_family_reach.py @@ -0,0 +1,170 @@ +"""A family idea reaches every project on its platform, and no other +(milestone 463 step 6). + +WHY THIS IS AN INTEGRATION TEST — the lesson-reach reasoning (#3730) holds +here too: the reach is one `OR` inside the search's project filter, and only +the ROWS that come back prove it. Every note embeds identically to the query, +so scoping is the only thing that can separate them. The embedder is stubbed; +no similarity is asserted, only membership. + +The corpus: an idea written in android one, canon for the Android platform, +with a Kotlin and a Python reference. Android two writes Python, so it should +reach the idea and the Python reference only. The Go service is on no shared +platform and should reach none of it. +""" +import uuid +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.embedding import EMBEDDING_DIM, NoteEmbedding +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.services import family_adoption as adoption_svc +from scribe.services.embeddings import CHUNKER_VERSION, EMBEDDING_MODEL, semantic_search_notes +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + +QUERY_VEC = [1.0] + [0.0] * (EMBEDDING_DIM - 1) + + +async def _platform(s, slug: str) -> int: + return await s.scalar(select(Platform.id).where( + Platform.slug == slug, Platform.deleted_at.is_(None))) + + +@pytest_asyncio.fixture +async def corpus(): + tag = uuid.uuid4().hex[:8] + async with async_session() as s: + owner = await ensure_user(s, f"family_reach_owner_{tag}") + await s.flush() + android, go = await _platform(s, "android-app"), await _platform(s, "go") + a = Project(user_id=owner.id, title="android one") + b = Project(user_id=owner.id, title="android two") + c = Project(user_id=owner.id, title="go service") + s.add_all([a, b, c]) + await s.flush() + s.add_all([ + ProjectPlatform(project_id=a.id, platform_id=android, state="declared"), + ProjectPlatform(project_id=b.id, platform_id=android, state="declared"), + ProjectPlatform(project_id=c.id, platform_id=go, state="declared"), + ]) + + def snippet(project, title, language): + return Note(user_id=owner.id, project_id=project.id, note_type="snippet", + title=title, body=title, data={"language": language}) + + rows = { + "idea": Note(user_id=owner.id, project_id=a.id, note_type="note", + title="Signed APK lane", body="one keystore, in-place update"), + "kotlin_ref": snippet(a, "installUpdate (kotlin)", "kotlin"), + "python_ref": snippet(a, "install_update (python)", "python"), + "note_on_a": Note(user_id=owner.id, project_id=a.id, note_type="note", + title="An ordinary note", body="ordinary"), + "b_own": snippet(b, "b's own python helper", "python"), + } + s.add_all(rows.values()) + await s.flush() + s.add(FamilyIdea(note_id=rows["idea"].id, status="canon", + applies_when="any Android app that ships its own APK")) + await s.flush() + s.add_all([ + FamilyIdeaPlatform(note_id=rows["idea"].id, platform_id=android), + FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["kotlin_ref"].id), + FamilyIdeaReference(idea_id=rows["idea"].id, snippet_id=rows["python_ref"].id), + ]) + for note in rows.values(): + s.add(NoteEmbedding( + note_id=note.id, chunk_index=0, user_id=owner.id, + embedding=QUERY_VEC, chunk_text=note.title, + chunker_version=CHUNKER_VERSION, embedding_model=EMBEDDING_MODEL, + )) + ids = {k: n.id for k, n in rows.items()} + ids.update(owner=owner.id, a=a.id, b=b.id, c=c.id) + await s.commit() + return ids + + +async def _search(uid, **kw): + with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)): + hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw) + return {note.id for _score, note in hits} + + +async def test_an_idea_reaches_a_project_on_its_platform_with_the_reference_in_its_language(corpus): + found = await _search(corpus["owner"], project_id=corpus["b"], include_global_kinds=True) + assert {corpus["idea"], corpus["python_ref"], corpus["b_own"]} <= found + # Android two writes Python: the Kotlin reference stays with the idea. + assert corpus["kotlin_ref"] not in found + assert corpus["note_on_a"] not in found + + +async def test_an_idea_does_not_reach_a_project_on_no_shared_platform(corpus): + found = await _search(corpus["owner"], project_id=corpus["c"], include_global_kinds=True) + assert not found & {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]} + + +async def test_the_reach_is_off_unless_the_search_widens_the_project(corpus): + """The near-duplicate gate searches a project without widening it; a + family idea there would block a create on a project it was never + written in.""" + found = await _search(corpus["owner"], project_id=corpus["b"]) + assert found == {corpus["b_own"]} + + +async def test_with_no_reference_in_the_projects_language_every_reference_comes(corpus): + async with async_session() as s: + b_own = await s.get(Note, corpus["b_own"]) + b_own.data = {"language": "dart"} + await s.commit() + reach = await adoption_svc.family_reach_ids(corpus["b"]) + assert reach == {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]} + + +async def test_a_retired_idea_reaches_nobody(corpus): + async with async_session() as s: + (await s.get(FamilyIdea, corpus["idea"])).status = "retired" + await s.commit() + assert await adoption_svc.family_reach_ids(corpus["b"]) == set() + + +# --- the entry readout and the owed answers a close hands back ------------------- + +async def test_the_entry_readout_counts_what_the_project_has_to_answer(corpus): + line = await adoption_svc.family_readout(corpus["owner"], corpus["b"]) + assert (line["unassessed"], line["owed"], line["needs_recheck"]) == (1, 0, 0) + assert line["calls"]["unassessed"] == ( + f'list_family_adoptions(project_id={corpus["b"]}, status="unassessed")') + # Nothing reaches the Go service, so it carries no line at all. + assert await adoption_svc.family_readout(corpus["owner"], corpus["c"]) is None + + +async def test_owed_since_lists_what_is_still_owed_and_only_since_then(corpus): + from datetime import datetime, timedelta, timezone + + async with async_session() as s: + s.add(FamilyAdoption(project_id=corpus["b"], idea_id=corpus["idea"], status="owed", + reason="no in-place update yet", canon_version=1, + decided_via="agent")) + s.add(FamilyDecision(idea_id=corpus["idea"], project_id=corpus["b"], action="assess", + reason="no in-place update yet", + before={"status": "unassessed", "reason": "", "canon_version": None}, + after={"status": "owed", "reason": "no in-place update yet", + "canon_version": 1}, + evidence={}, precedent_ids=[], decided_via="agent", + user_id=corpus["owner"])) + await s.commit() + now = datetime.now(timezone.utc) + [owed] = await adoption_svc.owed_since(corpus["owner"], now - timedelta(minutes=5)) + assert (owed["idea_id"], owed["project_id"], owed["project_title"]) == ( + corpus["idea"], corpus["b"], "android two") + assert await adoption_svc.owed_since(corpus["owner"], now + timedelta(minutes=5)) == [] diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index e40dd61e..c3bd8634 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -1,4 +1,5 @@ """Tests for fable_*_project tools.""" +import contextlib from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -48,6 +49,16 @@ def _no_coverage(): yield +@pytest.fixture(autouse=True) +def _no_family(): + """enter_project reads the family-canon readout (milestone 463 step 6) — + no database here, so the common case is stubbed: nothing to answer. The + populated key is asserted in its own test below.""" + with patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout", + AsyncMock(return_value=None)) as mock: + yield mock + + @pytest.fixture(autouse=True) def _no_background_seed(): """enter_project now fire-and-forgets a coverage self-seed (#2802). These @@ -483,3 +494,33 @@ def test_inception_routes_and_tool_are_registered(): # Rules left inception with subscriptions (milestone 414). assert "subscribe_rulebooks" not in tool.parameters.get("properties", {}) + + +@pytest.mark.asyncio +async def test_enter_project_carries_the_family_line_only_when_it_asks_something(_no_family): + """The `family` key is attached when the project has family canon to + answer, and absent otherwise — a key that usually says null trains + readers to skip it (#2483).""" + line = {"line": "family canon on this project's platforms: 2 unassessed", + "unassessed": 2, "owed": 0, "needs_recheck": 0, + "calls": {"unassessed": 'list_family_adoptions(project_id=5, status="unassessed")'}} + + async def enter(): + with contextlib.ExitStack() as stack: + for cm in _enter_project_stubs(fake_project(id=5)): + stack.enter_context(cm) + return await enter_project(project_id=5) + + assert "family" not in await enter() + _no_family.return_value = line + assert (await enter())["family"] == line + + +@pytest.mark.asyncio +async def test_a_failing_family_readout_never_fails_the_handshake(_no_family): + _no_family.side_effect = RuntimeError("database down") + with contextlib.ExitStack() as stack: + for cm in _enter_project_stubs(fake_project(id=5)): + stack.enter_context(cm) + out = await enter_project(project_id=5) + assert "family" not in out and out["project"]["id"] == 5 diff --git a/tests/test_mcp_tool_report_back_cue.py b/tests/test_mcp_tool_report_back_cue.py index 7d2129c5..f06338e9 100644 --- a/tests/test_mcp_tool_report_back_cue.py +++ b/tests/test_mcp_tool_report_back_cue.py @@ -16,15 +16,17 @@ pytestmark = pytest.mark.usefixtures("_bind_user") _PREF = {"id": 41, "title": "Say how it was checked", "statement": "…", "kind": "preference"} -async def _update(prefs=None, **kwargs): +async def _update(prefs=None, owed=None, **kwargs): from scribe.mcp.tools.tasks import update_task - note = MagicMock(id=5, user_id=7, project_id=3) + note = MagicMock(id=5, user_id=7, project_id=3, started_at="2026-10-06T09:00:00+00:00") note.to_dict.return_value = {"id": 5} lookup = AsyncMock(return_value=list(prefs or [])) with patch("scribe.mcp.tools.tasks.notes_svc.update_note", AsyncMock(return_value=note)), \ patch("scribe.mcp.tools.tasks.systems_tools.attach_systems", AsyncMock()), \ patch("scribe.mcp.tools.tasks.placement_svc.attach_placement", AsyncMock()), \ + patch("scribe.mcp.tools.tasks.family_adoption_svc.owed_since", + AsyncMock(return_value=list(owed or []))), \ patch("scribe.mcp.tools.tasks.reply_prefs_svc.completion_preferences", lookup): return await update_task(task_id=5, **kwargs), lookup @@ -56,3 +58,22 @@ async def test_no_preferences_means_no_key_and_the_plain_cue(): out, _ = await _update(prefs=[], status="done") assert "reply_preferences" not in out assert out["report_back"] == REPORT_BACK_CUE + + +_OWED = {"idea_id": 9, "idea_title": "Signed APK lane", "project_id": 4, + "project_title": "android two", "owed_task_id": 77} + + +async def test_closing_names_the_owed_adoptions_filed_while_the_task_was_open(): + """The finishing moment of milestone 463 step 6: owed family work filed + into a project during this task comes back for the report to name.""" + from scribe.services.family_adoption import OWED_CUE + + out, _ = await _update(owed=[_OWED], status="done") + assert out["family_owed"] == [_OWED] + assert out["report_back"].endswith(OWED_CUE) and "family_owed" in out["report_back"] + + +async def test_no_owed_adoptions_means_no_key(): + out, _ = await _update(owed=[], status="done") + assert "family_owed" not in out diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py index eabdb007..fdf8bffc 100644 --- a/tests/test_milestone_summary_brief.py +++ b/tests/test_milestone_summary_brief.py @@ -117,6 +117,8 @@ def _enter_stubs(project, milestones: list[dict], tasks: list, *, rules=None, sy AsyncMock(return_value=design)), patch("scribe.mcp.tools.projects.coverage_svc.cached_coverage", AsyncMock(return_value=None)), + patch("scribe.mcp.tools.projects.family_adoption_svc.family_readout", + AsyncMock(return_value=None)), patch("scribe.mcp.tools.projects.spawn"), ] -- 2.54.0 From b8efff43b427ed3dab5f6c6fe46f722073bf40b1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 13:35:42 -0400 Subject: [PATCH 10/10] fix(family): the reach tests assert on their own corpus; the shared integration database holds other tests' canon on the same platform (#4992) Co-Authored-By: Claude Opus 5.5 --- tests/test_integration_family_reach.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/test_integration_family_reach.py b/tests/test_integration_family_reach.py index b7f0416d..45e5e92e 100644 --- a/tests/test_integration_family_reach.py +++ b/tests/test_integration_family_reach.py @@ -94,6 +94,14 @@ async def corpus(): return ids +def _family(corpus) -> set[int]: + """This corpus's family records. The reach is computed across every + user's canon (the search applies readability afterwards), and the + integration database holds other tests' Android ideas, so assertions + about it are made on this corpus's ids only.""" + return {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]} + + async def _search(uid, **kw): with patch("scribe.services.embeddings.get_embedding", AsyncMock(return_value=QUERY_VEC)): hits = await semantic_search_notes(uid, "how does the app update itself", limit=20, **kw) @@ -127,14 +135,14 @@ async def test_with_no_reference_in_the_projects_language_every_reference_comes( b_own.data = {"language": "dart"} await s.commit() reach = await adoption_svc.family_reach_ids(corpus["b"]) - assert reach == {corpus["idea"], corpus["kotlin_ref"], corpus["python_ref"]} + assert reach & _family(corpus) == _family(corpus) async def test_a_retired_idea_reaches_nobody(corpus): async with async_session() as s: (await s.get(FamilyIdea, corpus["idea"])).status = "retired" await s.commit() - assert await adoption_svc.family_reach_ids(corpus["b"]) == set() + assert not await adoption_svc.family_reach_ids(corpus["b"]) & _family(corpus) # --- the entry readout and the owed answers a close hands back ------------------- -- 2.54.0