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
+7 -2
View File
@@ -163,9 +163,11 @@ _READ_ONLY_TOOLS = frozenset({
# The platform catalog and a project's answers (milestone 463). A pure
# read; set_project_platforms is the write.
"list_platforms",
# Family canon (milestone 463): ideas, one idea with its precedents, and
# the decision log. Reads of records the caller can read.
# Family canon (milestone 463): ideas, one idea with its precedents, the
# decision log, and the adoption ledger. Reads of records the caller can
# read.
"list_family_ideas", "get_family_idea", "list_family_decisions",
"get_family_adoption", "list_family_adoptions",
# The pass over the corpus and its queue (milestone 458 step 7): which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
@@ -193,6 +195,9 @@ _WRITE_TOOLS = frozenset({
# family canon — the promotion engine
"propose_family_idea", "promote_family_idea", "retire_family_idea",
"undo_family_decision",
# family canon — the adoption ledger
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
"set_family_references",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+205 -11
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import family as family_svc
from scribe.services import family_adoption as adoption_svc
async def list_family_ideas(
@@ -32,8 +33,9 @@ async def list_family_ideas(
async def get_family_idea(note_id: int) -> dict:
"""One family idea with what an evaluation needs: its state, platforms,
full decision history, ledger counts, THE THREE CRITERIA, and the
`precedents` — the decisions on the ideas nearest this one by meaning.
full decision history, ledger counts, THE THREE CRITERIA, the `ledger`
(every project's answer, with `needs_recheck`), and the `precedents` —
the decisions on the ideas nearest this one by meaning.
Read the precedents before deciding, and decide consistently with them
unless this idea differs in a way you can name.
@@ -45,6 +47,7 @@ async def get_family_idea(note_id: int) -> dict:
if idea is None:
raise ValueError(f"#{note_id} is not a family idea you can read")
idea["precedents"] = await family_svc.precedents(uid, note_id)
idea["ledger"] = await adoption_svc.list_adoptions(uid, idea_id=note_id)
return idea
@@ -146,10 +149,12 @@ async def retire_family_idea(note_id: int, reason: str) -> dict:
async def undo_family_decision(decision_id: int, reason: str) -> dict:
"""Reverse a family decision — a promotion, a retirement or a proposal —
restoring the state it recorded as `before`. Only the latest idea-level
decision on an idea can be undone; the undo is logged too, so the history
keeps both.
"""Reverse a family decision — a promotion, a revision, a retirement, a
proposal, or one project's assessment — restoring the state it recorded
as `before`. Only the latest decision on an idea (or on one project's
answer to it) can be undone; the undo is logged too, so the history
keeps both. Undoing an owed answer closes its task; undoing back to owed
reopens it.
Args:
decision_id: the decision (list_family_decisions).
@@ -158,26 +163,215 @@ async def undo_family_decision(decision_id: int, reason: str) -> dict:
return await family_svc.undo(current_user_id(), decision_id, reason=reason)
async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict:
async def list_family_decisions(
note_id: int = 0, project_id: int = 0, limit: int = 50, offset: int = 0,
) -> dict:
"""The family decision log, newest first: every proposal, promotion,
veto, retirement and undo, with its reason, evidence and the precedents
it followed. `undoable` marks the decision an undo would reverse.
veto, revision, retirement, assessment and undo, with its reason,
evidence and the precedents it followed. `undoable` marks the decision an
undo would reverse — per idea, and per project's answer.
Args:
note_id: only this idea's decisions. 0 = all.
project_id: only this project's assessments. 0 = all.
limit / offset: page through the log.
"""
rows = await family_svc.list_decisions(
current_user_id(), idea_id=note_id or None,
current_user_id(), idea_id=note_id or None, project_id=project_id or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"decisions": rows, "limit": limit, "offset": offset}
async def revise_family_idea(
note_id: int, reason: str, applies_when: str = "", platforms: list[str] | None = None,
evidence: list[str] | None = None,
) -> dict:
"""Record that a canon idea's SUBSTANCE changed — you rewrote its note's
approach, its traps or its checklist, or its applicability or platforms
moved. The canon version moves, so every project's answer given against
the old version reads `needs_recheck` until it is assessed again.
A typo fix is not a revision. A change a project that adopted the old
version would need to act on is.
Args:
note_id: the canon idea.
reason: what changed, in a sentence.
applies_when: a new 'when it applies'. Empty = keep the current one.
platforms: new platform slugs. Omit = keep the current ones.
evidence: what prompted the change, if anything.
"""
return await family_svc.revise(
current_user_id(), note_id, reason=reason,
applies_when=applies_when or None, platforms=platforms, evidence=evidence,
)
async def get_family_adoption(project_id: int, idea_id: int) -> dict:
"""Everything one assessment needs: the idea (its 'when it applies',
platforms and version), this project's current answer, THE FOUR
OUTCOMES, the `precedents` — this idea's answers in other projects and
this project's answers to the nearest ideas — and the reference
implementations beside this project's languages. Read it before
assess_family_adoption.
Args:
project_id: the project answering.
idea_id: the canon idea.
"""
return await adoption_svc.get_adoption(current_user_id(), project_id, idea_id)
async def list_family_adoptions(
project_id: int = 0, idea_id: int = 0, status: str = "", needs_recheck: bool = False,
platform: str = "",
) -> dict:
"""The adoption ledger: each project's answer to each canon idea that
reaches it — `unassessed`, `adopted`, `variant`, `exempt` or `owed`, with
its reason, the canon version it was given against, `needs_recheck` when
the idea has moved on since, and the owed task.
Args:
project_id: one project's answers. 0 = all you can read.
idea_id: one idea's answers. 0 = all canon.
status: one outcome. Empty = all.
needs_recheck: only answers given against an older canon version.
platform: only ideas for this platform slug.
"""
rows = await adoption_svc.list_adoptions(
current_user_id(), project_id=project_id or None, idea_id=idea_id or None,
status=status or None, recheck_only=needs_recheck, platform=platform or None,
)
return {"adoptions": rows}
async def assess_family_adoption(
project_id: int,
idea_id: int,
outcome: str,
reason: str,
evidence: list[str] | None = None,
precedent_ids: list[int] | None = None,
system_ids: list[int] | None = None,
) -> dict:
"""Answer one canon family idea for one project. YOU decide; nobody
approves. Judge in this order and stop at the first that holds:
1. exempt — the idea's 'when it applies' is false for this project.
`reason` names the fact about the project that makes it false.
2. variant — it applies, and the project departs for a reason that names
a FACT about itself the canon did not account for. A preference, a
taste, or "we already did it another way" is not a reason: that is
owed — or, if this project's way is better rather than different, a
conflict (resolve_family_conflict).
3. adopted — it applies and the project does it. `evidence` names where
(a file, a commit, a task, a CI run).
4. owed — none of the above. A task is filed in THIS project naming the
gap and the reference implementation for its language. Nothing edits
another repository; the project picks the task up itself.
Read get_family_adoption first: answer consistently with its precedents
unless this project differs in a way you can name in `reason`. The
engine also records the precedents it found itself.
The owed task follows the answer: adopted closes it as done, exempt or
variant cancels it, owed again reopens it. The same answer given twice
records nothing the second time.
Args:
project_id: the project answering.
idea_id: the canon idea.
outcome: exempt | variant | adopted | owed.
reason: why — required for every outcome.
evidence: where it is done (required for adopted), or what you checked.
precedent_ids: earlier family decisions you followed, if any.
system_ids: Systems for an owed task. Omit to match the idea's own.
"""
return await adoption_svc.assess(
current_user_id(), project_id, idea_id, outcome=outcome, reason=reason,
evidence=evidence, precedent_ids=precedent_ids, system_ids=system_ids,
)
async def resolve_family_conflict(
idea_id: int,
canon_project_id: int,
other_project_id: int,
ground: str,
fold: str,
reason: str,
grounds_checked: dict | None = None,
evidence: list[str] | None = None,
conditions: dict | None = None,
precedent_ids: list[int] | None = None,
) -> dict:
"""Settle two projects that solve the same canon idea differently, each
for reasons it believes. YOU decide, by THE CONFLICT ORDER — the first
ground that applies wins, and for every ground above it you say in
`grounds_checked` why it did not decide:
1. operator_stance — one side follows a stance the operator stated (a
rule, a preference, a recorded decision). Name it in `evidence`.
2. covers_failure — one side covers a recorded failure (an incident, an
issue, a lesson) the other does not. Name it in `evidence`. The other
side becomes owed.
3. split_by_condition — both are right under different conditions. The
canon splits: `conditions={"canon": …, "other": …}`, and each side is
canon where its condition holds.
4. most_recent_complete — none of the above: the side verified most
recently and covering the most wins. Name the verification.
`canon_project_id` is the side whose approach the idea's note states
after this. If the note says something else now, rewrite it first
(update_note) — the note is the canon.
The losing side's reasoning, `fold`, is appended to the idea's note as a
trap (ground 2), an alternative (1, 4) or the branch for its condition
(3). It is never dropped. The version moves, so every other project's
answer reads `needs_recheck`. The canon side is answered adopted; the
other owed (with a task in its project) or, in a split, adopted.
Args:
idea_id: the canon idea.
canon_project_id: the side whose approach is canon after this.
other_project_id: the side that loses, or the split's other branch.
ground: operator_stance | covers_failure | split_by_condition | most_recent_complete.
fold: the other side's reasoning, as it should read in the note.
reason: the decision in one or two sentences.
grounds_checked: {earlier ground: why it did not decide}.
evidence: the stance, the failure or the verification.
conditions: for a split, {"canon": condition, "other": condition}.
precedent_ids: earlier family decisions you followed, if any.
"""
return await adoption_svc.resolve_conflict(
current_user_id(), idea_id, canon_project_id=canon_project_id,
other_project_id=other_project_id, ground=ground, grounds_checked=grounds_checked,
fold=fold, evidence=evidence, reason=reason, conditions=conditions,
precedent_ids=precedent_ids,
)
async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
"""Set a family idea's reference implementations — the snippets an owed
task points a project at, ideally one per language. Replaces the list.
The idea is what transfers; a reference is where to start, not code to
copy.
Args:
note_id: the family idea.
snippet_ids: snippets implementing it. [] clears the list.
"""
refs = await adoption_svc.set_references(current_user_id(), note_id, snippet_ids or [])
return {"references": refs}
def register(mcp) -> None:
for fn in (
list_family_ideas, get_family_idea, propose_family_idea,
promote_family_idea, retire_family_idea, undo_family_decision,
list_family_decisions,
list_family_decisions, revise_family_idea, get_family_adoption,
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
set_family_references,
):
mcp.tool(name=fn.__name__)(fn)