feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:05:00 -04:00
co-authored by Claude Opus 5.5
parent faa1b72307
commit 201e09901b
12 changed files with 2386 additions and 57 deletions
+389
View File
@@ -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"])