feat(family): platforms, family ideas, the adoption ledger and its decision log (milestone 463 step 1, #4987)
CI & Build / TypeScript typecheck (push) Successful in 1m11s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 25s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Python tests (push) Successful in 2m41s
CI & Build / Build & push image (push) Successful in 1m8s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:33:27 -04:00
co-authored by Claude Opus 5.5
parent ccbccb025c
commit e68ccc5884
7 changed files with 1474 additions and 4 deletions
+6
View File
@@ -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,
)
+357
View File
@@ -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),
}