feat(family): an idea links to the rule topic holding its norms - set_family_topic, the readouts name the rules, the idea row shows the link (milestone 463 step 1 gap, found in step 7, #4993)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 1m9s
CI & Build / integration (push) Successful in 1m21s
CI & Build / Python tests (push) Successful in 2m2s
CI & Build / Build & push image (push) Successful in 1m29s

Step 1 added family_ideas.topic_id but nothing could set it, so the note<->topic
link the milestone promised existed only as a column. set_family_topic links or
unlinks (0) a topic in a rulebook the caller owns, one idea per topic; the
version does not move. get_family_idea and get_family_adoption list the topic's
rules; list_family_ideas and the Family page name the topic. The model
docstrings no longer claim the topic's rules are platform-scoped in retrieval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:27:33 -04:00
co-authored by Claude Opus 5.5
parent b8efff43b4
commit 07169e1274
10 changed files with 195 additions and 8 deletions
+1 -1
View File
@@ -196,7 +196,7 @@ _WRITE_TOOLS = frozenset({
"undo_family_decision",
# family canon — the adoption ledger
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
"set_family_references",
"set_family_references", "set_family_topic",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+20 -1
View File
@@ -372,12 +372,31 @@ async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
return {"references": refs}
async def set_family_topic(note_id: int, topic_id: int) -> dict:
"""Link a family idea to the rule topic that holds its binding norms — the
rules every project on its platforms follows. The note says when the idea
applies and why; the topic's rules say what must hold. One idea per topic.
get_family_idea and get_family_adoption then list those rules, so an
assessment judges against them.
Use it when an idea already has rules written for it, or when you write
rules that exist to carry one idea. The version does not move: linking
changes where the norms are found, not what they say.
Args:
note_id: the family idea.
topic_id: a topic in a rulebook you own (list_rulebooks,
list_topics). 0 unlinks.
"""
return await family_svc.set_topic(current_user_id(), note_id, topic_id)
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, revise_family_idea, get_family_adoption,
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
set_family_references,
set_family_references, set_family_topic,
):
mcp.tool(name=fn.__name__)(fn)
+6 -4
View File
@@ -23,8 +23,9 @@ The tables, and the one job each does:
- `family_ideas` — a note's family state: candidate, canon or retired, its
applicability test, its canon version, its linked rule topic.
- `family_idea_platforms` — the platforms an idea is for. The ONLY scope
source: a linked topic takes its scope from here rather than carrying its
own, so the two can never disagree.
source: a linked topic carries no platforms of its own, so the two can
never disagree. The scope decides which projects owe the idea an answer;
the topic's rules still reach a session by ordinary rule retrieval.
- `family_idea_references` — the reference implementations.
- `family_adoptions` — one row per (project, idea): the project's answer.
- `family_decisions` — the append-only log of every promotion and every
@@ -165,8 +166,9 @@ class FamilyIdea(Base, TimestampMixin):
comparing the two numbers, never stored as a flag that could go stale.
`topic_id` is the rule topic holding the idea's binding norms, if it has
any (the note↔topic link #3236 asked for). The topic takes its scope from
this idea; it never carries platforms of its own.
any (the note↔topic link #3236 asked for), set by `set_topic`. The topic
never carries platforms of its own; the idea's readouts list its rules,
so an assessment judges a project against them.
"""
__tablename__ = "family_ideas"
+60
View File
@@ -52,7 +52,9 @@ from scribe.models.family import (
)
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.rulebook import Rule
from scribe.services import access
from scribe.services import rulebooks as rulebooks_svc
logger = logging.getLogger(__name__)
@@ -140,6 +142,26 @@ async def _platform_slugs(session, note_id: int) -> list[str]:
return list(rows.scalars().all())
async def _topic(session, user_id: int, topic_id: int | None, *, rules: bool) -> dict | None:
"""The rule topic holding an idea's binding norms, for a reader. Rulebooks
belong to their owner, so a topic the reader does not own reads as no
topic."""
if not topic_id:
return None
topic = await rulebooks_svc.get_topic(topic_id, user_id)
if topic is None:
return None
out = {"id": topic.id, "title": topic.title, "rulebook_id": topic.rulebook_id}
if rules:
out["rules"] = [
{"id": r.id, "title": r.title} for r in (await session.execute(
select(Rule).where(Rule.topic_id == topic.id, Rule.deleted_at.is_(None))
.order_by(Rule.order_index.asc(), Rule.id.asc())
)).scalars().all()
]
return out
async def _snapshot(session, idea: FamilyIdea | None) -> dict | None:
"""The idea's state, as a decision's before/after. Slugs, never ids: an id
inside JSON cannot be remapped by a restore (the #3182 trap)."""
@@ -187,6 +209,7 @@ async def get_idea(user_id: int, note_id: int) -> dict | None:
"is_task": note.is_task,
"project_id": note.project_id,
"platforms": await _platform_slugs(session, note_id),
"topic": await _topic(session, user_id, idea.topic_id, rules=True),
"adoptions": counts,
"decisions": [_decision_dict(d) for d in decisions],
"undoable_decision_id": _undoable_id(decisions),
@@ -226,6 +249,7 @@ async def list_ideas(
"is_task": note_status is not None,
"project_id": project_id,
"platforms": await _platform_slugs(session, idea.note_id),
"topic": await _topic(session, user_id, idea.topic_id, rules=False),
})
out.append(d)
return out
@@ -677,6 +701,42 @@ async def revise(
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
async def set_topic(user_id: int, note_id: int, topic_id: int) -> dict:
"""Link an idea to the rule topic holding its binding norms, or unlink it
with topic_id 0. The note carries the idea; the topic carries the rules a
project on its platforms must follow. One idea per topic, since a topic's
norms belong to one standard.
Not a change of substance, so the version does not move: the norms were
rules before the link and are the same rules after it. Re-linking is the
undo, as with the references."""
if not await access.can_write_note(user_id, note_id):
raise ValueError(f"note {note_id} not found or no write access")
async with async_session() as session:
idea = await session.get(FamilyIdea, note_id)
if idea is None:
raise ValueError(f"#{note_id} is not a family idea")
if topic_id:
if await rulebooks_svc.get_topic(topic_id, user_id) is None:
raise ValueError(f"topic {topic_id} not found in a rulebook you own")
holder = (await session.execute(
select(FamilyIdea.note_id, Note.title)
.join(Note, Note.id == FamilyIdea.note_id)
.where(FamilyIdea.topic_id == topic_id, FamilyIdea.note_id != note_id)
)).first()
if holder is not None:
raise ValueError(
f"topic {topic_id} already carries the norms of #{holder[0]} "
f"“{holder[1]}” — unlink it there first, or merge the two ideas"
)
idea.topic_id = topic_id or None
idea.updated_at = _now()
await session.commit()
out = idea.to_dict()
out["topic"] = await _topic(session, user_id, idea.topic_id, rules=True)
return out
async def _existing_decision_ids(ids: list[int]) -> list[int]:
ids = [int(i) for i in ids if i]
if not ids: