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>
379 lines
17 KiB
Python
379 lines
17 KiB
Python
"""The promotion engine against real Postgres (milestone 463 step 3).
|
|
|
|
What the unit lane can't show: a promotion opens the right ledger rows and
|
|
no others, a veto changes nothing but the log, an undo puts back exactly the
|
|
recorded prior state, and each trigger fires on its fixture and stays silent
|
|
on the near-miss beside it.
|
|
|
|
Precedent search and the repeat trigger both rank by meaning, and the lane
|
|
has no embedding model. The search is stubbed to "nothing similar" for every
|
|
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
|
|
import pytest_asyncio
|
|
from sqlalchemy import select
|
|
|
|
from scribe.models import async_session
|
|
from scribe.models.family import (
|
|
FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform,
|
|
)
|
|
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
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
|
|
|
|
OWNER = "family_promotion_owner"
|
|
OUTSIDER = "family_promotion_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 backup round-trip 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():
|
|
"""Three projects: two Android apps and a Go service, the pattern note
|
|
in the first, and an outsider's Android app the owner cannot write."""
|
|
android, go = await _platform_id("android-app"), await _platform_id("go")
|
|
async with async_session() as s:
|
|
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")
|
|
theirs = Project(user_id=outsider.id, title="someone else's android app")
|
|
s.add_all([a, b, c, theirs])
|
|
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="detected"),
|
|
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
|
|
ProjectPlatform(project_id=theirs.id, platform_id=android, state="declared"),
|
|
])
|
|
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
|
|
body="One keystore, two channels, in-place update.")
|
|
s.add(pattern)
|
|
await s.commit()
|
|
return {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id,
|
|
"c": c.id, "theirs": theirs.id, "note": pattern.id}
|
|
|
|
|
|
async def _promote(f, **overrides):
|
|
kw = dict(
|
|
applies_when="any Android app that ships its own APK",
|
|
platforms=["android-app"], criteria=CRITERIA,
|
|
evidence=["CI green on the release lane", "verified on a device"],
|
|
reason="built twice, proven, stated for the platform",
|
|
)
|
|
kw.update(overrides)
|
|
return await family_svc.promote(f["owner"], f["note"], **kw)
|
|
|
|
|
|
async def _ledger(note_id: int) -> dict[int, str]:
|
|
async with async_session() as s:
|
|
rows = (await s.execute(
|
|
select(FamilyAdoption.project_id, FamilyAdoption.status)
|
|
.where(FamilyAdoption.idea_id == note_id)
|
|
)).all()
|
|
return dict(rows)
|
|
|
|
|
|
async def _idea(note_id: int) -> FamilyIdea:
|
|
async with async_session() as s:
|
|
return await s.get(FamilyIdea, note_id)
|
|
|
|
|
|
# --- promotion ------------------------------------------------------------------
|
|
|
|
async def test_promotion_opens_a_row_for_every_member_project_the_promoter_can_write(family):
|
|
out = await _promote(family)
|
|
assert out["promoted"] is True
|
|
# Declared and detected both count as membership; the Go service is not
|
|
# on the platform; the outsider's app is not the promoter's to write.
|
|
assert await _ledger(family["note"]) == {family["a"]: "unassessed",
|
|
family["b"]: "unassessed"}
|
|
idea = await _idea(family["note"])
|
|
assert idea.status == "canon" and idea.canon_version == 1
|
|
decision = out["decision"]
|
|
assert decision["action"] == "promote"
|
|
assert decision["after"]["platforms"] == ["android-app"]
|
|
assert decision["evidence"]["criteria"]["proven"] == CRITERIA["proven"]
|
|
assert decision["evidence"]["ledger_rows_opened"] == 2
|
|
|
|
|
|
@pytest.mark.parametrize("key", list(family_svc.CRITERIA_KEYS))
|
|
async def test_each_criterion_vetoes_on_its_own_and_the_veto_is_logged(family, key):
|
|
out = await _promote(family, criteria={**CRITERIA, key: ""})
|
|
assert out["promoted"] is False and out["vetoed_by"] == [key]
|
|
assert (await _idea(family["note"])).status == "candidate"
|
|
assert await _ledger(family["note"]) == {}
|
|
assert out["decision"]["action"] == "propose"
|
|
assert out["decision"]["evidence"]["vetoed_by"] == [key]
|
|
|
|
|
|
async def test_an_unknown_platform_is_refused_before_anything_is_written(family):
|
|
with pytest.raises(ValueError, match="unknown platform"):
|
|
await _promote(family, platforms=["android-app", "no-such-platform"])
|
|
assert await _idea(family["note"]) is None
|
|
|
|
|
|
async def test_someone_who_cannot_write_the_note_cannot_promote_it(family):
|
|
with pytest.raises(ValueError, match="no write access"):
|
|
await family_svc.promote(
|
|
family["outsider"], family["note"], applies_when="x", platforms=["android-app"],
|
|
criteria=CRITERIA, evidence=["e"], reason="r",
|
|
)
|
|
|
|
|
|
# --- undo and retirement ------------------------------------------------------------
|
|
|
|
async def test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows(family):
|
|
out = await _promote(family)
|
|
async with async_session() as s:
|
|
row = (await s.execute(select(FamilyAdoption).where(
|
|
FamilyAdoption.idea_id == family["note"],
|
|
FamilyAdoption.project_id == family["a"]))).scalars().one()
|
|
row.status, row.canon_version, row.decided_via = "adopted", 1, "agent"
|
|
await s.commit()
|
|
|
|
await family_svc.undo(family["owner"], out["decision"]["id"], reason="promoted too early")
|
|
idea = await _idea(family["note"])
|
|
# Promoted directly, with no proposal before it: the prior state was "not
|
|
# an idea", which an undo records as retired rather than deleting the
|
|
# idea and its log with it.
|
|
assert idea.status == "retired" and idea.applies_when is None
|
|
# The unjudged row goes; the judged one stays as history.
|
|
assert await _ledger(family["note"]) == {family["a"]: "adopted"}
|
|
|
|
again = await _promote(family)
|
|
# Re-promotion moves PAST every version this idea has held, so the kept
|
|
# answer reads as needing a recheck rather than as still agreeing.
|
|
assert again["idea"]["canon_version"] == 2
|
|
assert await _ledger(family["note"]) == {family["a"]: "adopted", family["b"]: "unassessed"}
|
|
|
|
|
|
async def test_retiring_closes_unjudged_rows_and_undoing_it_reopens_them(family):
|
|
await _promote(family)
|
|
out = await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
|
assert (await _idea(family["note"])).status == "retired"
|
|
assert await _ledger(family["note"]) == {}
|
|
await family_svc.undo(family["owner"], out["decision"]["id"], reason="not superseded after all")
|
|
assert (await _idea(family["note"])).status == "canon"
|
|
assert set((await _ledger(family["note"])).values()) == {"unassessed"}
|
|
|
|
|
|
async def test_only_the_latest_idea_decision_can_be_undone(family):
|
|
first = await _promote(family)
|
|
await family_svc.retire(family["owner"], family["note"], reason="superseded")
|
|
with pytest.raises(ValueError, match="came after it"):
|
|
await family_svc.undo(family["owner"], first["decision"]["id"], reason="r")
|
|
|
|
|
|
async def test_an_undo_names_what_it_reversed_as_its_precedent(family):
|
|
out = await _promote(family)
|
|
undo = await family_svc.undo(family["owner"], out["decision"]["id"], reason="r")
|
|
assert undo["decision"]["action"] == "undo"
|
|
assert undo["decision"]["precedent_ids"] == [out["decision"]["id"]]
|
|
async with async_session() as s:
|
|
actions = (await s.execute(select(FamilyDecision.action).where(
|
|
FamilyDecision.idea_id == family["note"]).order_by(FamilyDecision.id))).scalars().all()
|
|
assert actions == ["promote", "undo"]
|
|
|
|
|
|
# --- triggers -------------------------------------------------------------------------
|
|
|
|
async def _note_in(project_id: int, owner_id: int, body: str) -> Note:
|
|
async with async_session() as s:
|
|
note = Note(user_id=owner_id, project_id=project_id, title="the second build", body=body)
|
|
s.add(note)
|
|
await s.commit()
|
|
await s.refresh(note)
|
|
return note
|
|
|
|
|
|
async def test_a_cross_project_lineage_citation_opens_an_evaluation(family):
|
|
citing = await _note_in(family["b"], family["owner"],
|
|
f"Release lane, matching the first app (#{family['note']}).")
|
|
hint = await family_svc.citation_trigger(family["owner"], citing)
|
|
assert hint and f"#{family['note']}" in hint
|
|
idea = await _idea(family["note"])
|
|
assert idea is not None and idea.status == "candidate"
|
|
async with async_session() as s:
|
|
d = (await s.execute(select(FamilyDecision).where(
|
|
FamilyDecision.idea_id == family["note"]))).scalars().one()
|
|
assert d.decided_via == "system" and d.evidence["trigger"] == "citation"
|
|
|
|
|
|
async def test_a_citation_without_lineage_or_within_one_project_is_silent(family):
|
|
pointer = await _note_in(family["b"], family["owner"], f"See #{family['note']} for context.")
|
|
same_project = await _note_in(family["a"], family["owner"], f"Matching #{family['note']}.")
|
|
assert await family_svc.citation_trigger(family["owner"], pointer) is None
|
|
assert await family_svc.citation_trigger(family["owner"], same_project) is None
|
|
assert await _idea(family["note"]) is None
|
|
|
|
|
|
async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family):
|
|
"""The meaning match is stubbed — the lane has no model — so this pins
|
|
what the trigger does with a hit: only a hit in ANOTHER project that
|
|
shares a platform counts."""
|
|
repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.")
|
|
async with async_session() as s:
|
|
original = await s.get(Note, family["note"])
|
|
with patch("scribe.services.embeddings.semantic_search_notes",
|
|
AsyncMock(return_value=[(0.86, original)])):
|
|
hint = await family_svc.repeat_trigger(family["owner"], repeat)
|
|
assert hint and f"#{family['note']}" in hint
|
|
assert (await _idea(family["note"])).status == "candidate"
|
|
|
|
|
|
async def test_a_repeat_with_no_shared_platform_is_silent(family):
|
|
unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.")
|
|
async with async_session() as s:
|
|
original = await s.get(Note, family["note"])
|
|
with patch("scribe.services.embeddings.semantic_search_notes",
|
|
AsyncMock(return_value=[(0.86, original)])):
|
|
assert await family_svc.repeat_trigger(family["owner"], unrelated) is None
|
|
assert await _idea(family["note"]) is None
|
|
|
|
|
|
async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family):
|
|
async with async_session() as s:
|
|
on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1")
|
|
bare = Project(user_id=family["owner"], title="no platforms")
|
|
s.add_all([on_platform, bare])
|
|
await s.flush()
|
|
off_platform = Milestone(user_id=family["owner"], project_id=bare.id, title="m2")
|
|
s.add(off_platform)
|
|
await s.commit()
|
|
await s.refresh(on_platform)
|
|
await s.refresh(off_platform)
|
|
hint = await family_svc.milestone_trigger(family["owner"], on_platform)
|
|
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"])
|