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
@@ -181,6 +181,6 @@ def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
def test_every_adoption_tool_is_classified():
reads = {"get_family_adoption", "list_family_adoptions"}
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
"set_family_references"}
"set_family_references", "set_family_topic"}
assert reads <= _READ_ONLY_TOOLS
assert writes <= _WRITE_TOOLS
@@ -11,6 +11,7 @@ test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what
the trigger does WITH a hit. The ranking itself is the shared semantic
search, tested where that lives.
"""
import uuid
from unittest.mock import AsyncMock, patch
import pytest
@@ -24,6 +25,7 @@ from scribe.models.family import (
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.rulebook import Rule, Rulebook, RulebookTopic
from scribe.models.user import User
from scribe.services import family as family_svc
from tests.helpers import ensure_user
@@ -291,3 +293,86 @@ async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_
assert hint and "Android app" in hint
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
# --- the note↔topic link ----------------------------------------------------------
@pytest_asyncio.fixture
async def topics(family):
"""A topic of the owner's with two rules, a second empty one, and one in
the outsider's rulebook. Titles carry a nonce: rulebooks are not purged
with the notes, and a topic's title is unique within its rulebook."""
nonce = uuid.uuid4().hex[:8]
async with async_session() as s:
mine = Rulebook(owner_user_id=family["owner"], title=f"family norms {nonce}")
theirs = Rulebook(owner_user_id=family["outsider"], title=f"their norms {nonce}")
s.add_all([mine, theirs])
await s.flush()
norms = RulebookTopic(rulebook_id=mine.id, title="distribution")
spare = RulebookTopic(rulebook_id=mine.id, title="spare")
foreign = RulebookTopic(rulebook_id=theirs.id, title="theirs")
s.add_all([norms, spare, foreign])
await s.flush()
s.add_all([
Rule(topic_id=norms.id, title="One release key", statement="Sign with one key.",
order_index=0),
Rule(topic_id=norms.id, title="No debug signer", statement="Check the signer.",
order_index=1),
])
await s.commit()
return {"norms": norms.id, "spare": spare.id, "foreign": foreign.id,
"rulebook": mine.id}
async def test_an_idea_links_to_its_topic_and_its_reads_name_the_rules(family, topics):
await _promote(family)
out = await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
assert out["topic_id"] == topics["norms"]
assert [r["title"] for r in out["topic"]["rules"]] == ["One release key", "No debug signer"]
# Where the norms are found is not what they say: the version holds.
assert out["canon_version"] == 1
idea = await family_svc.get_idea(family["owner"], family["note"])
assert idea["topic"]["rulebook_id"] == topics["rulebook"]
assert len(idea["topic"]["rules"]) == 2
[listed] = [i for i in await family_svc.list_ideas(family["owner"])
if i["note_id"] == family["note"]]
assert listed["topic"] == {"id": topics["norms"], "title": "distribution",
"rulebook_id": topics["rulebook"]}
async def test_zero_unlinks_the_topic(family, topics):
await _promote(family)
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
out = await family_svc.set_topic(family["owner"], family["note"], 0)
assert out["topic_id"] is None and out["topic"] is None
async def test_a_topic_outside_the_callers_rulebooks_is_refused(family, topics):
await _promote(family)
with pytest.raises(ValueError, match="rulebook you own"):
await family_svc.set_topic(family["owner"], family["note"], topics["foreign"])
assert (await _idea(family["note"])).topic_id is None
async def test_one_topic_carries_one_ideas_norms(family, topics):
await _promote(family)
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
async with async_session() as s:
other = Note(user_id=family["owner"], project_id=family["b"], title="Another idea",
body="something else every Android app needs")
s.add(other)
await s.flush()
s.add(FamilyIdea(note_id=other.id, status="candidate"))
await s.commit()
other_id = other.id
with pytest.raises(ValueError, match="already carries the norms"):
await family_svc.set_topic(family["owner"], other_id, topics["norms"])
# Re-linking the same idea to its own topic is not a conflict.
await family_svc.set_topic(family["owner"], family["note"], topics["norms"])
async def test_someone_who_cannot_write_the_idea_cannot_link_it(family, topics):
await _promote(family)
with pytest.raises(ValueError, match="no write access"):
await family_svc.set_topic(family["outsider"], family["note"], topics["spare"])