"""Family canon's promotion engine (milestone 463 step 3). A family idea moves between three states — candidate, canon, retired — and every move is a decision with a reason, written to `family_decisions`. No person approves a promotion: the agent decides against the written criteria below, and the log is what keeps one decision consistent with the last similar one, and what lets a person read or undo any of them afterwards. WHO DECIDES WHAT - TRIGGERS (`citation_trigger`, `repeat_trigger`, `milestone_trigger`) only ever OPEN an evaluation. The first two record the source record as a `candidate` (decided_via "system") and hand the writer an in-band hint; a closed milestone is not a record an idea can hang on, so it only hints. None of them promotes. - THE AGENT evaluates a candidate against the three criteria and either promotes it or leaves it a candidate with the reason. A criterion with no support vetoes on its own; the veto is logged too, because a held candidate is precedent for the next one like it. - A PERSON reads the log, and may retire an idea or undo a decision from the web door. Those are the only operator acts, and neither is required. PRECEDENT Every promotion records the earlier decisions it was consistent with. The engine finds them itself — the decisions on the ideas nearest this one by meaning — and stores them alongside any the caller names, so "which precedents were consulted" is a fact about the call, not a sentence a session might skip. LEDGER ROWS AND UNDO (settled here, as the milestone asked) Promotion opens an `unassessed` row for every project that is a member of one of the idea's platforms and that the promoter can write. Leaving canon — by retirement, or by undoing the promotion — deletes the `unassessed` rows, because nobody judged them and they would only be noise. Rows somebody DID judge (adopted, variant, exempt, owed) are kept: each is a decision with its reason, and if the idea is promoted again its version moves, so every kept row reads as needing a recheck rather than as still agreeing. """ from __future__ import annotations import logging import re from datetime import datetime, timezone from sqlalchemy import delete, func, select from scribe.models import async_session from scribe.models.family import ( FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, Platform, ProjectPlatform, ) 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__) # --- the criteria, as product text -------------------------------------------- # # These are what an evaluation is judged against, on every install. They live # here — and in the tool docstrings that quote them — rather than in any # instance's rulebook (rules 115, 119). CRITERIA = ( { "key": "platform_terms", "title": "Stated in platform terms", "test": ( "Its 'when it applies' is stated in terms of a platform — what the " "project is built on or ships as — not one app's domain. Any project " "on the platform could read it and know whether it applies." ), }, { "key": "platform_problem", "title": "Answers a platform problem or a cross-project stance", "test": ( "It answers a problem the platform itself causes, or a stance the " "operator holds across projects. One app's preference does not " "qualify." ), }, { "key": "proven", "title": "Proven at least once", "test": ( "It has worked for real at least once — CI green, verified on a " "device, or shipped — and the evidence is named. An unproven idea " "stays a candidate." ), }, ) CRITERIA_KEYS = tuple(c["key"] for c in CRITERIA) TRIGGERS = ("citation", "repeat", "milestone") # The repeat trigger's similarity floor. MEASURED, not guessed (2026-10-06, # #4989): against a description of one pattern known to have been built four # times in four projects, the records that implement it scored 0.79-0.82, and # the best match in a project that never built it scored 0.65. A trigger only # opens an evaluation, so a false positive costs one judgment, not a wrong # promotion. Step 5 measures the cross-language threshold against known pairs # and supersedes this. REPEAT_THRESHOLD = 0.80 # Words that say a citation is the SOURCE of a pattern rather than background. # "see #12" is a pointer; "matching #12" and "ported from #12" are lineage. _LINEAGE = re.compile( r"\b(match(?:es|ing)?|mirror(?:s|ing|ed)?|same (?:shape|pattern|approach|design) as|" r"ported from|copied from|borrowed from|lifted from|taken from|based on|" r"modell?ed on|follow(?:s|ing)? the (?:shape|pattern|approach) of|as (?:built|done) in|" r"reuses?|re-?implement(?:s|ing)?|like)\b", re.I, ) # How far before a `#N` the lineage word may sit: the same clause, roughly. _LINEAGE_WINDOW = 80 # Note kinds the repeat trigger compares: recorded knowledge and shapes, not # the to-do list. _REPEAT_KINDS = ("snippet", "note") MEMBER_STATES = ("declared", "detected") _IDEA_ACTIONS = ("propose", "promote", "revise", "retire") def _now() -> datetime: return datetime.now(timezone.utc) # --- reads -------------------------------------------------------------------- async def _platform_slugs(session, note_id: int) -> list[str]: rows = await session.execute( select(Platform.slug) .join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == Platform.id) .where(FamilyIdeaPlatform.note_id == note_id) .order_by(Platform.order_index.asc(), Platform.slug.asc()) ) 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).""" if idea is None: return None return { "status": idea.status, "applies_when": idea.applies_when or "", "canon_version": idea.canon_version, "platforms": await _platform_slugs(session, idea.note_id), } def _decision_dict(d: FamilyDecision, title: str | None = None) -> dict: out = d.to_dict() if title is not None: out["idea_title"] = title return out async def get_idea(user_id: int, note_id: int) -> dict | None: """One idea with everything an evaluation reads: its state, platforms, decision history (newest first), its ledger counts and the criteria. None when the caller cannot read the note or it is not an idea.""" if not await access.can_read_note(user_id, note_id): return None async with async_session() as session: idea = await session.get(FamilyIdea, note_id) note = await session.get(Note, note_id) if idea is None or note is None: return None decisions = (await session.execute( select(FamilyDecision).where(FamilyDecision.idea_id == note_id) .order_by(FamilyDecision.id.desc()) )).scalars().all() counts = dict((await session.execute( select(FamilyAdoption.status, func.count()) .where(FamilyAdoption.idea_id == note_id) .group_by(FamilyAdoption.status) )).all()) out = idea.to_dict() out.update({ "title": note.title, "note_type": note.note_type or "note", "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), }) out["criteria"] = list(CRITERIA) return out async def list_ideas( user_id: int, *, status: str | None = None, platform: str | None = None, limit: int = 50, offset: int = 0, ) -> list[dict]: """Ideas whose note the caller can read, newest change first.""" async with async_session() as session: query = ( select(FamilyIdea, Note.title, Note.note_type, Note.project_id, Note.status) .join(Note, Note.id == FamilyIdea.note_id) .where(access.readable_notes_clause(user_id), Note.deleted_at.is_(None)) ) if status: query = query.where(FamilyIdea.status == status) if platform: query = query.where(FamilyIdea.note_id.in_( select(FamilyIdeaPlatform.note_id) .join(Platform, Platform.id == FamilyIdeaPlatform.platform_id) .where(Platform.slug == platform) )) rows = (await session.execute( query.order_by(FamilyIdea.updated_at.desc()).limit(limit).offset(offset) )).all() out = [] for idea, title, note_type, project_id, note_status in rows: d = idea.to_dict() d.update({ "title": title, "note_type": note_type or "note", "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 async def list_decisions( user_id: int, *, idea_id: int | None = None, project_id: int | None = None, limit: int = 50, offset: int = 0, ) -> list[dict]: """The decision log, newest first, over ideas the caller can read. Each row says whether it is the one an undo would reverse; a project's assessment also carries the project's title.""" async with async_session() as session: query = ( select(FamilyDecision, Note.title, Project.title) .join(Note, Note.id == FamilyDecision.idea_id) .outerjoin(Project, Project.id == FamilyDecision.project_id) .where(access.readable_notes_clause(user_id)) ) if idea_id: query = query.where(FamilyDecision.idea_id == idea_id) if project_id: query = query.where(FamilyDecision.project_id == project_id) rows = (await session.execute( query.order_by(FamilyDecision.id.desc()).limit(limit).offset(offset) )).all() latest = await _latest_idea_decisions( session, {d.idea_id for d, _, _ in rows if d.project_id is None}) latest_pair = await _latest_pair_decisions( session, {(d.idea_id, d.project_id) for d, _, _ in rows if d.project_id is not None}) out = [] for d, title, project_title in rows: item = _decision_dict(d, title) if d.project_id is None: item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d) else: item["project_title"] = project_title item["undoable"] = latest_pair.get((d.idea_id, d.project_id)) == d.id and _can_undo(d) out.append(item) return out def _can_undo(d: FamilyDecision) -> bool: """A decision that changed something: an idea-level one, or one project's assessment. A veto (a `propose` whose before and after agree) changed nothing, and an undo is undone by deciding again, not by undoing the undo.""" if d.before == d.after: return False if d.project_id is None: return d.action in _IDEA_ACTIONS return d.action == "assess" def _undoable_id(decisions_newest_first) -> int | None: for d in decisions_newest_first: if d.project_id is None: return d.id if _can_undo(d) else None return None async def _latest_pair_decisions(session, pairs: set[tuple[int, int]]) -> dict[tuple[int, int], int]: """The latest decision about each (idea, project) answer — the only one of a project's decisions on an idea that an undo may reverse.""" if not pairs: return {} rows = await session.execute( select(FamilyDecision.idea_id, FamilyDecision.project_id, func.max(FamilyDecision.id)) .where( FamilyDecision.idea_id.in_({i for i, _ in pairs}), FamilyDecision.project_id.in_({p for _, p in pairs}), ) .group_by(FamilyDecision.idea_id, FamilyDecision.project_id) ) return {(i, p): d for i, p, d in rows.all() if (i, p) in pairs} async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]: if not idea_ids: return {} rows = await session.execute( select(FamilyDecision.idea_id, func.max(FamilyDecision.id)) .where(FamilyDecision.idea_id.in_(idea_ids), FamilyDecision.project_id.is_(None)) .group_by(FamilyDecision.idea_id) ) return dict(rows.all()) async def nearest_ideas(user_id: int, note_id: int, limit: int = 5) -> list[tuple[float, Note]]: """The family ideas nearest this record by meaning, best first, as (score, note). Empty when there are no other ideas or the embedder is unavailable — precedent is a help to a decision, never a gate on it.""" from scribe.services.embeddings import embedding_text, semantic_search_notes async with async_session() as session: note = await session.get(Note, note_id) if note is None: return [] idea_ids = set((await session.execute(select(FamilyIdea.note_id))).scalars().all()) idea_ids.discard(note_id) if not idea_ids: return [] try: hits = await semantic_search_notes( user_id, embedding_text(note.title, note.body), exclude_ids={note_id}, limit=40, threshold=0.0, scope="read", include_global_kinds=True, demote_superseded=False, ) except Exception: logger.warning("precedent search failed for idea %s", note_id, exc_info=True) return [] return [(score, n) for score, n in hits if n.id in idea_ids][:limit] async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]: """The decisions on the ideas nearest this one by meaning — what a new decision about it should be consistent with. Each idea contributes its latest idea-level decision. Empty when nothing similar has been decided, or when the embedder is unavailable.""" ranked = await nearest_ideas(user_id, note_id, limit) if not ranked: return [] async with async_session() as session: latest = await _latest_idea_decisions(session, {n.id for _, n in ranked}) decisions = { d.id: d for d in (await session.execute( select(FamilyDecision).where(FamilyDecision.id.in_(list(latest.values()))) )).scalars().all() } out = [] for score, n in ranked: d = decisions.get(latest.get(n.id)) if d is not None: item = _decision_dict(d, n.title) item["similarity"] = round(float(score), 3) out.append(item) return out # --- writes --------------------------------------------------------------------- def _log(session, *, idea_id: int, action: str, reason: str, before, after, evidence: dict | None, precedent_ids: list[int] | None, decided_via: str, user_id: int | None, project_id: int | None = None) -> FamilyDecision: row = FamilyDecision( idea_id=idea_id, project_id=project_id, action=action, reason=reason.strip(), before=before, after=after, evidence=evidence or {}, precedent_ids=list(dict.fromkeys(precedent_ids or [])), decided_via=decided_via, user_id=user_id, ) session.add(row) return row async def _resolve_platforms(session, slugs: list[str]) -> dict[str, int]: wanted = list(dict.fromkeys(s.strip() for s in slugs if s and s.strip())) if not wanted: return {} rows = (await session.execute( select(Platform.slug, Platform.id) .where(Platform.slug.in_(wanted), Platform.deleted_at.is_(None)) )).all() known = dict(rows) unknown = [s for s in wanted if s not in known] if unknown: raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)") return known async def _set_platforms(session, note_id: int, platform_ids) -> None: await session.execute(delete(FamilyIdeaPlatform).where(FamilyIdeaPlatform.note_id == note_id)) for pid in dict.fromkeys(platform_ids): session.add(FamilyIdeaPlatform(note_id=note_id, platform_id=pid)) async def _open_ledger(session, user_id: int, note_id: int) -> int: """An `unassessed` row for every member project of the idea's platforms that the promoter can write and that has no row yet. Returns how many.""" project_ids = set((await session.execute( select(ProjectPlatform.project_id) .join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id) .join(Project, Project.id == ProjectPlatform.project_id) .where( FamilyIdeaPlatform.note_id == note_id, ProjectPlatform.state.in_(MEMBER_STATES), Project.deleted_at.is_(None), ) )).scalars().all()) answered = set((await session.execute( select(FamilyAdoption.project_id).where(FamilyAdoption.idea_id == note_id) )).scalars().all()) opened = 0 for pid in sorted(project_ids - answered): # Rule 78: a promotion reaches only the projects its promoter could # have written an answer into themselves. if await access.can_write_project(user_id, pid): session.add(FamilyAdoption(project_id=pid, idea_id=note_id, status="unassessed")) opened += 1 return opened async def _close_unassessed(session, note_id: int) -> int: result = await session.execute( delete(FamilyAdoption).where( FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed", ) ) return result.rowcount or 0 async def propose( user_id: int, note_id: int, *, reason: str, trigger: str = "agent", evidence: dict | None = None, applies_when: str | None = None, decided_via: str = "agent", ) -> tuple[dict, bool]: """Record a note as a family-idea CANDIDATE. Returns (idea, created). Idempotent: an existing idea comes back unchanged and nothing is logged — a trigger firing on every edit of a record must not grow the log. Write- gated on the note: an idea is state on someone's record (rule 78). """ if not (reason or "").strip(): raise ValueError("a proposal needs a reason — what makes this a family idea?") 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 not None: return idea.to_dict(), False idea = FamilyIdea(note_id=note_id, status="candidate", applies_when=(applies_when or "").strip() or None) session.add(idea) await session.flush() _log( session, idea_id=note_id, action="propose", reason=reason, before=None, after=await _snapshot(session, idea), evidence={"trigger": trigger, **(evidence or {})}, precedent_ids=None, decided_via=decided_via, user_id=user_id, ) await session.commit() return idea.to_dict(), True def vetoes(*, applies_when: str, platforms: list[str], criteria: dict, evidence: list) -> list[str]: """The criteria a promotion fails, by key. Pure. Each fails ON ITS OWN when its support is missing: - platform_terms — no reasoning, no `applies_when`, or no platform scope; - platform_problem — no reasoning; - proven — no reasoning, or no named evidence. """ def said(key: str) -> bool: return bool(str(criteria.get(key) or "").strip()) failed = [] if not said("platform_terms") or not (applies_when or "").strip() or not platforms: failed.append("platform_terms") if not said("platform_problem"): failed.append("platform_problem") if not said("proven") or not [e for e in evidence or [] if str(e).strip()]: failed.append("proven") return failed async def promote( user_id: int, note_id: int, *, applies_when: str, platforms: list[str], criteria: dict, evidence: list[str], reason: str, precedent_ids: list[int] | None = None, decided_via: str = "agent", ) -> dict: """Evaluate a record against the three criteria and promote it to canon. A record nobody proposed may be promoted directly — the candidate row is created on the way. Any criterion without support vetoes the promotion: the idea stays (or becomes) a candidate and the veto is logged as a `propose` decision naming what failed, so it is precedent too. On promotion: status canon, `applies_when` and the platform scope set, the version bumped if this idea has been promoted before (so rows judged against the earlier canon read as needing a recheck), an `unassessed` ledger row opened per member project, and a decision logged with the criteria reasoning, the evidence and the precedents consulted. Raises ValueError on a malformed call (no reason, unknown platform, no write access, already canon) — before anything is written. """ if not (reason or "").strip(): raise ValueError("a promotion needs a reason") if not await access.can_write_note(user_id, note_id): raise ValueError(f"note {note_id} not found or no write access") criteria = {k: str((criteria or {}).get(k) or "").strip() for k in CRITERIA_KEYS} evidence = [str(e).strip() for e in evidence or [] if str(e).strip()] consulted = await precedents(user_id, note_id) named = await _existing_decision_ids(precedent_ids or []) precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted])) async with async_session() as session: known = await _resolve_platforms(session, platforms or []) idea = await session.get(FamilyIdea, note_id) if idea is not None and idea.status == "canon": raise ValueError( f"#{note_id} is already canon (version {idea.canon_version}); " "retire it first, or record a revision" ) created_now = idea is None if created_now: idea = FamilyIdea(note_id=note_id, status="candidate") session.add(idea) await session.flush() # A row made by this call had no prior state: `before` says so, which # also makes a veto that created a candidate undoable (it changed # something — a record became a candidate). before = None if created_now else await _snapshot(session, idea) failed = vetoes( applies_when=applies_when, platforms=list(known), criteria=criteria, evidence=evidence, ) record = {"criteria": criteria, "evidence": evidence} if failed: decision = _log( session, idea_id=note_id, action="propose", reason=f"held as a candidate — fails {', '.join(failed)}: {reason.strip()}", before=before, after=await _snapshot(session, idea), evidence={**record, "vetoed_by": failed}, precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id, ) await session.commit() await session.refresh(decision) return { "promoted": False, "vetoed_by": failed, "idea": idea.to_dict(), "decision": decision.to_dict(), "precedents": consulted, } # Re-promotion moves the version PAST every version this idea has ever # held — including one an undo rolled back from — so no row judged # against an earlier canon can read as agreeing with this one. if await _promoted_before(session, note_id): idea.canon_version = await next_version(session, idea) idea.status = "canon" idea.applies_when = applies_when.strip() idea.updated_at = _now() await _set_platforms(session, note_id, known.values()) await session.flush() opened = await _open_ledger(session, user_id, note_id) decision = _log( session, idea_id=note_id, action="promote", reason=reason, before=before, after=await _snapshot(session, idea), evidence={**record, "ledger_rows_opened": opened}, precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id, ) await session.commit() await session.refresh(decision) return { "promoted": True, "idea": idea.to_dict(), "ledger_rows_opened": opened, "decision": decision.to_dict(), "precedents": consulted, } async def _promoted_before(session, note_id: int) -> bool: return bool(await session.scalar( select(func.count()).select_from(FamilyDecision) .where(FamilyDecision.idea_id == note_id, FamilyDecision.action == "promote") )) async def next_version(session, idea: FamilyIdea) -> int: """One past every canon version this idea has ever held — including one an undo rolled back from — so no answer given against an earlier canon can read as agreeing with the new one. Only idea-level snapshots carry an idea version; an assessment's `after` holds the version it was judged against, which is never ahead of the idea's.""" rows = (await session.execute( select(FamilyDecision.after).where( FamilyDecision.idea_id == idea.note_id, FamilyDecision.project_id.is_(None), ) )).scalars().all() seen = [idea.canon_version or 1] + [int(a.get("canon_version") or 1) for a in rows if a] return max(seen) + 1 async def _close_unreached(session, note_id: int) -> int: """Delete the `unassessed` rows of projects no longer on any of the idea's platforms — nobody judged them, and the idea no longer reaches them. Judged rows stay, as history.""" members = ( select(ProjectPlatform.project_id) .join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id) .where(FamilyIdeaPlatform.note_id == note_id, ProjectPlatform.state.in_(MEMBER_STATES)) ) result = await session.execute( delete(FamilyAdoption).where( FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed", FamilyAdoption.project_id.not_in(members), ) ) return result.rowcount or 0 async def revise( user_id: int, note_id: int, *, reason: str, applies_when: str | None = None, platforms: list[str] | None = None, evidence: list[str] | None = None, decided_via: str = "agent", ) -> dict: """Record that a canon idea's SUBSTANCE changed — its note was rewritten, its applicability narrowed or widened, or its platforms changed. The version moves, so every project's answer given against the earlier version reads as needing a recheck (derived, never a stored flag). A project newly in scope gets an `unassessed` row; an `unassessed` row of a project no longer in scope is closed. Undoable like any idea-level decision. `applies_when` and `platforms` are left alone when None; given, they replace the old ones and may not be empty (canon is always scoped). """ if not (reason or "").strip(): raise ValueError("a revision needs a reason — what changed in the idea?") if not await access.can_write_note(user_id, note_id): raise ValueError(f"note {note_id} not found or no write access") if applies_when is not None and not applies_when.strip(): raise ValueError("canon needs an 'applies when' — leave it out to keep the current one") if platforms is not None and not [p for p in platforms if (p or "").strip()]: raise ValueError("canon needs at least one platform — leave it out to keep the current ones") evidence = [str(e).strip() for e in evidence or [] if str(e).strip()] async with async_session() as session: idea = await session.get(FamilyIdea, note_id) if idea is None or idea.status != "canon": raise ValueError(f"#{note_id} is not family canon — only canon is revised") known = await _resolve_platforms(session, platforms) if platforms is not None else None before = await _snapshot(session, idea) idea.canon_version = await next_version(session, idea) if applies_when is not None: idea.applies_when = applies_when.strip() if known is not None: await _set_platforms(session, note_id, known.values()) idea.updated_at = _now() await session.flush() opened = await _open_ledger(session, user_id, note_id) closed = await _close_unreached(session, note_id) decision = _log( session, idea_id=note_id, action="revise", reason=reason, before=before, after=await _snapshot(session, idea), evidence={"evidence": evidence, "ledger_rows_opened": opened, "ledger_rows_closed": closed}, precedent_ids=None, decided_via=decided_via, user_id=user_id, ) await session.commit() await session.refresh(decision) return {"idea": idea.to_dict(), "decision": decision.to_dict(), "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: return [] async with async_session() as session: found = set((await session.execute( select(FamilyDecision.id).where(FamilyDecision.id.in_(ids)) )).scalars().all()) missing = [i for i in ids if i not in found] if missing: raise ValueError(f"no such family decision(s): {', '.join(map(str, missing))}") return ids async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str = "agent") -> dict: """Demote an idea. Its history and its judged ledger rows are kept; the rows nobody judged are closed. Undoable.""" if not (reason or "").strip(): raise ValueError("retiring an idea needs a reason") 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 idea.status == "retired": raise ValueError(f"#{note_id} is already retired") before = await _snapshot(session, idea) idea.status = "retired" idea.updated_at = _now() closed = await _close_unassessed(session, note_id) decision = _log( session, idea_id=note_id, action="retire", reason=reason, before=before, after=await _snapshot(session, idea), evidence={"ledger_rows_closed": closed}, precedent_ids=None, decided_via=decided_via, user_id=user_id, ) await session.commit() await session.refresh(decision) return {"idea": idea.to_dict(), "decision": decision.to_dict()} async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str = "agent") -> dict: """Reverse a decision, restoring the state it recorded as `before`. A project's assessment is handed to the adoption ledger (family_adoption.undo_assessment); the rest of this is idea-level. Only the LATEST idea-level decision on an idea can be undone — undoing an older one would rewrite a state later decisions were built on. The undo is itself a decision, naming the one it reverses as its precedent, so the history keeps both. Undoing the proposal that created an idea retires it rather than deleting it: deleting the idea would take its decision log with it. """ if not (reason or "").strip(): raise ValueError("an undo needs a reason") async with async_session() as session: target = await session.get(FamilyDecision, decision_id) if target is None: raise ValueError(f"no such family decision: {decision_id}") if target.project_id is not None: # One project's answer: the adoption ledger restores the row. from scribe.services import family_adoption return await family_adoption.undo_assessment( user_id, target, reason=reason, decided_via=decided_via) if not await access.can_write_note(user_id, target.idea_id): raise ValueError(f"decision {decision_id} not found or no write access") async with async_session() as session: latest_id = (await _latest_idea_decisions(session, {target.idea_id})).get(target.idea_id) if latest_id != target.id or not _can_undo(target): if not _can_undo(target): why = ("it changed nothing" if target.before == target.after else f"a '{target.action}' decision is not undone this way") else: why = f"decision {latest_id} came after it — undo that one first" raise ValueError(f"decision {decision_id} cannot be undone: {why}") idea = await session.get(FamilyIdea, target.idea_id) before = await _snapshot(session, idea) # No recorded prior state means the decision CREATED the idea: before # it, the record had no applicability test and no scope. It comes back # as retired, with neither, rather than being deleted with its log. prior = target.before or { "status": "retired", "applies_when": "", "canon_version": idea.canon_version, "platforms": [], } if prior["status"] == "canon" and not (prior.get("applies_when") or "").strip(): raise ValueError("the recorded prior state is canon with no 'applies when'") known = await _resolve_platforms(session, prior.get("platforms") or []) idea.status = prior["status"] idea.applies_when = (prior.get("applies_when") or "").strip() or None idea.canon_version = prior.get("canon_version") or idea.canon_version idea.updated_at = _now() await _set_platforms(session, idea.note_id, known.values()) await session.flush() ledger: dict = {} if idea.status == "canon": ledger["ledger_rows_opened"] = await _open_ledger(session, user_id, idea.note_id) else: ledger["ledger_rows_closed"] = await _close_unassessed(session, idea.note_id) decision = _log( session, idea_id=idea.note_id, action="undo", reason=reason, before=before, after=await _snapshot(session, idea), evidence={"undid_action": target.action, **ledger}, precedent_ids=[target.id], decided_via=decided_via, user_id=user_id, ) await session.commit() await session.refresh(decision) return {"idea": idea.to_dict(), "decision": decision.to_dict()} # --- triggers ------------------------------------------------------------------- def lineage_citations(text: str | None) -> list[int]: """The `#N`s in a text that are cited as the SOURCE of a pattern — a lineage word ("matching", "ported from", "same shape as", …) in the same clause just before the reference. Pure, in order, de-duplicated.""" from scribe.services.record_refs import REF_RE found: list[int] = [] for m in REF_RE.finditer(text or ""): start = max(0, m.start() - _LINEAGE_WINDOW) window = text[start:m.start()] window = re.split(r"[\n.;]", window)[-1] if _LINEAGE.search(window): n = int(m.group(1)) if n not in found: found.append(n) return found async def _member_platform_ids(session, project_id: int) -> set[int]: return set((await session.execute( select(ProjectPlatform.platform_id).where( ProjectPlatform.project_id == project_id, ProjectPlatform.state.in_(MEMBER_STATES), ) )).scalars().all()) def _evaluate_line(note_id: int) -> str: return ( f"Evaluate it now: get_family_idea({note_id}) shows the three criteria and " "the nearest precedents; then promote_family_idea if all three hold, or " "leave it a candidate (promote_family_idea records which criterion " "failed). No one approves this — the criteria decide." ) async def citation_trigger(user_id: int, note) -> str | None: """A record that cites ANOTHER project's record as the source of its pattern opens an evaluation of that source as a family idea. Silent on a citation without lineage words, and on a citation within one project.""" if not getattr(note, "project_id", None): return None cited = [n for n in lineage_citations(note.body) if n != note.id] if not cited: return None async with async_session() as session: rows = (await session.execute( select(Note.id, Note.title, Note.project_id, Project.title) .join(Project, Project.id == Note.project_id) .where( Note.id.in_(cited), Note.deleted_at.is_(None), Note.project_id.is_not(None), Note.project_id != note.project_id, ) )).all() for source_id, source_title, _pid, project_title in sorted(rows, key=lambda r: cited.index(r[0])): if not await access.can_write_note(user_id, source_id): continue idea, created = await propose( user_id, source_id, trigger="citation", decided_via="system", reason=f"#{note.id} cites it as the source of its pattern, across projects", evidence={"cited_by": {"id": note.id, "title": note.title}}, ) if idea["status"] == "canon": return ( f"This record follows #{source_id} \"{source_title}\" ({project_title}), " "which is already family canon. This project answers it through " "its adoption ledger rather than by copying it." ) lead = "is now a family-idea candidate" if created else "is already a candidate" return ( f"This record cites #{source_id} \"{source_title}\" ({project_title}) as " f"the source of its pattern — an idea carried between projects, which " f"{lead}. {_evaluate_line(source_id)}" ) return None async def repeat_trigger(user_id: int, note) -> str | None: """A new record whose meaning repeats a record in ANOTHER project that shares a platform with this one opens an evaluation of the earlier record. Silent when the projects share no platform, below the threshold, or when the project has no platforms yet.""" from scribe.services.embeddings import embedding_text, semantic_search_notes if not getattr(note, "project_id", None) or note.is_task: return None async with async_session() as session: mine = await _member_platform_ids(session, note.project_id) if not mine: return None hits = await semantic_search_notes( user_id, embedding_text(note.title, note.body), exclude_ids={note.id}, limit=8, threshold=REPEAT_THRESHOLD, scope="read", note_type=_REPEAT_KINDS, is_task=False, demote_superseded=False, ) for score, other in hits: if not other.project_id or other.project_id == note.project_id: continue async with async_session() as session: shared = mine & await _member_platform_ids(session, other.project_id) if not shared or not await access.can_write_note(user_id, other.id): continue idea, created = await propose( user_id, other.id, trigger="repeat", decided_via="system", reason=(f"#{note.id} repeats it in another project on a shared platform " f"(similarity {score:.2f})"), evidence={"repeated_by": {"id": note.id, "title": note.title}, "similarity": round(float(score), 3)}, ) if idea["status"] == "canon": return ( f"This record repeats #{other.id} \"{other.title}\", which is already " "family canon on a platform this project shares. Build from it." ) lead = "is now a family-idea candidate" if created else "is already a candidate" return ( f"This record repeats #{other.id} \"{other.title}\" from another project " f"on a platform this one shares (similarity {score:.2f}). Two projects " f"building the same idea is what family canon is for; #{other.id} {lead}. " f"{_evaluate_line(other.id)}" ) return None async def milestone_is_open(user_id: int, milestone_id: int) -> bool: """Whether a milestone is open right now — read before a status write, so the trigger fires on the transition into done and not on every re-save of a closed one. Fail-open to False: a hint must never break the write.""" from scribe.services import milestones as milestones_svc try: milestone = await milestones_svc.get_milestone(user_id, milestone_id) except Exception: logger.warning("milestone %s status read failed", milestone_id, exc_info=True) return False return milestone is not None and milestone.status != "done" async def milestone_trigger(user_id: int, milestone) -> str | None: """A milestone closing in a project that is on a platform: the moment to ask whether what it built is something every project on that platform will face. Records nothing — a milestone is not a record an idea can hang on — so the hint asks for the note that would carry the idea.""" project_id = getattr(milestone, "project_id", None) try: if not project_id or not await access.can_read_project(user_id, project_id): return None async with async_session() as session: names = (await session.execute( select(Platform.name) .join(ProjectPlatform, ProjectPlatform.platform_id == Platform.id) .where(ProjectPlatform.project_id == project_id, ProjectPlatform.state.in_(MEMBER_STATES), Platform.deleted_at.is_(None)) .order_by(Platform.order_index.asc()) )).scalars().all() except Exception: # Fail-open: the milestone is already closed; the hint is decoration. logger.warning("milestone trigger failed for %s", getattr(milestone, "id", None), exc_info=True) return None if not names: return None return ( f"This milestone closed on {', '.join(names)}. If it solved something every " "project on those platforms will face — a problem the platform causes, or a " "stance held across projects — the idea belongs in the family: write it up " "as a note (when it applies, the traps, what proved it) and " "propose_family_idea it, or promote_family_idea if it already meets the " "three criteria. If what it built is this project's alone, nothing to do." ) async def attach_family_hint(user_id: int, data: dict, note, *, created: bool) -> None: """Ride the trigger hints on a write's response — fail-open: a hint must never break the write it rides on. Citation first (an explicit claim of lineage), then, on a create, the repeat check.""" try: hint = await citation_trigger(user_id, note) if hint is None and created: hint = await repeat_trigger(user_id, note) if hint: data["family_hint"] = hint except Exception: logger.warning("family trigger failed for note %s", getattr(note, "id", None), exc_info=True)