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
+69
View File
@@ -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"}