CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m13s
CI & Build / Python tests (push) Successful in 1m54s
CI & Build / Build & push image (push) Successful in 40s
Each decision stored its reason three times (reason, before.reason, after.reason) and a promotion carried the whole criteria reasoning in evidence. Precedents (assess, promote, get_family_adoption, get_family_idea) and get_family_idea's history now serve FamilyDecision.to_brief(): reason once, before/after as status, canon_version and platforms, evidence as the cited list. list_family_decisions and the decision echoed back by a write keep to_dict. The precedent count was already bounded at five. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.8 KiB
Python
114 lines
4.8 KiB
Python
"""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"}
|
|
|
|
|
|
# --- a decision read as precedent (#5131) ------------------------------------------
|
|
|
|
def _assessment():
|
|
reason = "the APK is built in CI and signed with the one release key"
|
|
return family.FamilyDecision(
|
|
id=7, idea_id=3, project_id=5, action="assess", reason=reason,
|
|
before={"status": "unassessed", "reason": "", "canon_version": None},
|
|
after={"status": "adopted", "reason": reason, "canon_version": 2},
|
|
evidence={"evidence": ["ci.yml::android-release"], "source": "shapes"},
|
|
precedent_ids=[4, 6], decided_via="agent", user_id=1,
|
|
)
|
|
|
|
|
|
def test_a_brief_carries_the_reason_once():
|
|
brief = _assessment().to_brief()
|
|
assert brief["reason"].startswith("the APK is built")
|
|
assert brief["before"] == {"status": "unassessed", "canon_version": None}
|
|
assert brief["after"] == {"status": "adopted", "canon_version": 2}
|
|
# Nothing in it says the reason a second time.
|
|
assert repr(brief).count("the APK is built") == 1
|
|
|
|
|
|
def test_a_brief_keeps_the_cited_evidence_and_drops_the_rest():
|
|
promotion = family.FamilyDecision(
|
|
id=8, idea_id=3, project_id=None, action="promote", reason="proven twice",
|
|
before=None,
|
|
after={"status": "canon", "applies_when": "an app that ships its own APK",
|
|
"canon_version": 1, "platforms": ["android-app"]},
|
|
evidence={"criteria": {"proven": "a long paragraph"}, "evidence": ["#12"],
|
|
"ledger_rows_opened": 3},
|
|
)
|
|
brief = promotion.to_brief()
|
|
assert brief["evidence"] == ["#12"]
|
|
assert brief["before"] is None
|
|
assert brief["after"] == {"status": "canon", "canon_version": 1,
|
|
"platforms": ["android-app"]}
|
|
|
|
|
|
def test_the_full_row_is_still_the_audit_shape():
|
|
full = _assessment().to_dict()
|
|
assert full["after"]["reason"] == full["reason"]
|
|
assert full["evidence"]["source"] == "shapes" and full["precedent_ids"] == [4, 6]
|