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
+162 -27
View File
@@ -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: