CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 56s
CI & Build / integration (push) Successful in 1m38s
CI & Build / Python tests (push) Successful in 2m16s
CI & Build / Build & push image (push) Successful in 32s
A dated release log (a note) matched a signed-APK CI snippet at 0.83 on shared release vocabulary and was opened as a family candidate, then came back as a precedent. One chunk of a long narrative scores like the shape it mentions, so a cross-kind score does not mean "the same thing made twice". The search is filtered to the new record's kind and the loop checks it too. Code built from an idea written elsewhere still reaches the engine through the citation trigger and the shape ledger. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
399 lines
18 KiB
Python
399 lines
18 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_repeat_across_kinds_is_silent(family):
|
|
"""A snippet whose only match is a NOTE is not a repeat (#5130): a dated
|
|
release log scored 0.83 against a signed-APK CI snippet on shared
|
|
vocabulary and was opened as a candidate. The stub hands back the
|
|
cross-kind hit directly, so this pins the trigger's own check, not the
|
|
search filter in front of it."""
|
|
async with async_session() as s:
|
|
snippet = Note(user_id=family["owner"], project_id=family["b"], note_type="snippet",
|
|
title="android-release lane", body="sign with the one release key")
|
|
s.add(snippet)
|
|
await s.commit()
|
|
await s.refresh(snippet)
|
|
original = await s.get(Note, family["note"])
|
|
assert original.note_type == "note"
|
|
with patch("scribe.services.embeddings.semantic_search_notes",
|
|
AsyncMock(return_value=[(0.86, original)])):
|
|
assert await family_svc.repeat_trigger(family["owner"], snippet) 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"])
|