feat(family): the promotion engine - triggers, the three criteria, the decision log and undo (milestone 463 step 3, #4989)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 19s
CI & Build / TypeScript typecheck (push) Successful in 57s
CI & Build / integration (push) Failing after 1m5s
CI & Build / Python tests (push) Successful in 1m58s
CI & Build / Build & push image (push) Skipped

The agent promotes a family idea when all three criteria hold, and no person approves it. The criteria are product text: services/family.py states them, and the promote tool's docstring names every criterion the service enforces.

- Criteria: each one vetoes on its own when its reasoning is blank. Platform terms also needs an applies-when and a platform scope; proven also needs named evidence. A veto keeps the idea a candidate and is logged, so it becomes precedent.
- Precedent: every promotion stores the decisions on the nearest ideas by meaning, plus any the caller names.
- Promotion sets canon, the applicability test and the platform scope, and opens an unassessed ledger row for each member project the promoter can write. Re-promotion moves the version past every version the idea has held.
- Retire and undo: undo reverses only the latest idea-level decision, restores its recorded before-state, and logs itself with the undone decision as its precedent. Settled here: leaving canon closes the unassessed rows but keeps the judged ones, which read as needing a recheck after a re-promotion.
- Triggers open evaluations but never promote:
  - a cross-project lineage citation ("matching #N") on a note or task write;
  - a same-meaning record in another project on a shared platform, on create, at 0.80 (measured: the known pattern's builds scored 0.79-0.82, an unrelated project's best match 0.65);
  - a milestone closing on a platform.
  Each fails open and rides the response as family_hint.
- Doors: seven MCP tools, /api/family REST endpoints, and a Family page (nav, /family) showing the criteria, the ideas, and the decision log with undo and retire.
- utils/recordHref.ts holds the one copy of "where a record opens", now shared with LessonDetailView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 10:33:30 -04:00
co-authored by Claude Opus 5.5
parent 1774ee3696
commit 8aacc1824c
21 changed files with 2151 additions and 12 deletions
+2
View File
@@ -33,6 +33,7 @@ from scribe.routes.dashboard import dashboard_bp
from scribe.routes.systems import systems_bp
from scribe.routes.canonical_systems import canonical_systems_bp
from scribe.routes.platforms import platforms_bp
from scribe.routes.family import family_bp
from scribe.routes.lessons import lessons_bp
from scribe.routes.snippets import snippets_bp
from scribe.routes.webhooks import webhooks_bp
@@ -103,6 +104,7 @@ def create_app() -> Quart:
app.register_blueprint(systems_bp)
app.register_blueprint(canonical_systems_bp)
app.register_blueprint(platforms_bp)
app.register_blueprint(family_bp)
app.register_blueprint(snippets_bp)
app.register_blueprint(webhooks_bp)
+6
View File
@@ -163,6 +163,9 @@ _READ_ONLY_TOOLS = frozenset({
# The platform catalog and a project's answers (milestone 463). A pure
# read; set_project_platforms is the write.
"list_platforms",
# Family canon (milestone 463): ideas, one idea with its precedents, and
# the decision log. Reads of records the caller can read.
"list_family_ideas", "get_family_idea", "list_family_decisions",
# The pass over the corpus and its queue (milestone 458 step 7): which
# rules are unjudged and which proposals wait. Reads of the caller's own
# rules, as list_rules is.
@@ -187,6 +190,9 @@ _WRITE_TOOLS = frozenset({
"create_project", "update_project", "delete_project", "decide_project_inception",
"create_system", "update_system", "delete_system", "map_system_to_canonical",
"set_project_platforms",
# family canon — the promotion engine
"propose_family_idea", "promote_family_idea", "retire_family_idea",
"undo_family_decision",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+2 -1
View File
@@ -5,7 +5,7 @@ to an MCPServer instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from scribe.mcp.tools import (
design_systems, lessons, milestones, notes, processes, projects, recent, repos,
design_systems, family, lessons, milestones, notes, processes, projects, recent, repos,
moments, platforms, retrieval_review, retrieval_tuning,
wide_net,
rulebooks, search, shapes, snippets, systems, tags, tasks, trash,
@@ -25,6 +25,7 @@ def register_all(mcp) -> None:
milestones.register(mcp)
systems.register(mcp)
platforms.register(mcp)
family.register(mcp)
design_systems.register(mcp)
tags.register(mcp)
recent.register(mcp)
+183
View File
@@ -0,0 +1,183 @@
"""Family canon MCP tools — the promotion engine's agent door (milestone 463).
Thin wrappers over services/family.py. The agent is the decider here: no
person approves a promotion, so the criteria are stated in these docstrings
and enforced by the service, and every decision is logged with its reason.
"""
from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import family as family_svc
async def list_family_ideas(
status: str = "", platform: str = "", limit: int = 50, offset: int = 0,
) -> dict:
"""Family ideas — records that every project on a platform shares — with
their state: `candidate` (waiting to be evaluated, or held back by a
criterion), `canon` (promoted; every project on its platforms answers it)
or `retired`.
Args:
status: candidate | canon | retired. Empty = all.
platform: a platform slug (list_platforms). Empty = all.
limit / offset: page through the list.
"""
ideas = await family_svc.list_ideas(
current_user_id(), status=status or None, platform=platform or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"ideas": ideas, "limit": limit, "offset": offset}
async def get_family_idea(note_id: int) -> dict:
"""One family idea with what an evaluation needs: its state, platforms,
full decision history, ledger counts, THE THREE CRITERIA, and the
`precedents` — the decisions on the ideas nearest this one by meaning.
Read the precedents before deciding, and decide consistently with them
unless this idea differs in a way you can name.
Args:
note_id: the idea's record id.
"""
uid = current_user_id()
idea = await family_svc.get_idea(uid, note_id)
if idea is None:
raise ValueError(f"#{note_id} is not a family idea you can read")
idea["precedents"] = await family_svc.precedents(uid, note_id)
return idea
async def propose_family_idea(note_id: int, reason: str, applies_when: str = "") -> dict:
"""Record a note, snippet or lesson as a family-idea CANDIDATE: something
you believe every project on some platform will face. It reaches no
project's ledger until it is promoted.
Proposing is cheap and does not promote. When the record already meets
the three criteria, call promote_family_idea directly instead.
Args:
note_id: the record that carries the idea.
reason: why this looks like a family idea.
applies_when: optional draft of when it applies, in platform terms.
"""
idea, created = await family_svc.propose(
current_user_id(), note_id, reason=reason, applies_when=applies_when or None,
)
return {"idea": idea, "created": created}
async def promote_family_idea(
note_id: int,
applies_when: str,
platforms: list[str],
platform_terms: str,
platform_problem: str,
proven: str,
evidence: list[str],
reason: str,
precedent_ids: list[int] | None = None,
) -> dict:
"""Evaluate a record against the three criteria and, if all hold, promote
it to family canon. YOU decide; nobody approves. The record of why is what
makes the decision reviewable and the next one consistent.
An idea is promoted only when ALL THREE hold, and each is answered with
your reasoning:
1. platform_terms — its 'when it applies' is stated in terms of a
platform (what a 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.
2. platform_problem — it answers a problem the platform itself causes, or
a stance the operator holds across projects. One app's preference does
not qualify.
3. proven — it has worked for real at least once (CI green, verified on a
device, shipped). Name that in `evidence`.
Any criterion left blank — or `applies_when`, `platforms` or `evidence`
left empty — VETOES the promotion on its own. A veto is not an error: the
idea stays a candidate and the veto is logged, so the next evaluation of
something like it sees why this one was held. That is also how you record
"evaluated, and it does not qualify": leave the failing criterion blank
and say why in `reason`.
Before deciding, read get_family_idea's `precedents`. The engine also
finds the nearest earlier decisions itself and stores them as consulted.
On promotion every project that is on one of `platforms` and that you can
write gets an `unassessed` row in its adoption ledger. If the idea was
canon before, its version moves, so earlier answers read as needing a
recheck.
Args:
note_id: the record that carries the idea.
applies_when: when it applies, in platform terms.
platforms: platform slugs it is for (list_platforms).
platform_terms: why criterion 1 holds (blank = it does not).
platform_problem: why criterion 2 holds (blank = it does not).
proven: why criterion 3 holds (blank = it does not).
evidence: what proved it — a CI run, a task, a commit, a device check.
reason: the decision in one or two sentences.
precedent_ids: earlier family decisions you followed, if any.
"""
return await family_svc.promote(
current_user_id(), note_id,
applies_when=applies_when, platforms=platforms or [],
criteria={
"platform_terms": platform_terms,
"platform_problem": platform_problem,
"proven": proven,
},
evidence=evidence or [], reason=reason, precedent_ids=precedent_ids or [],
)
async def retire_family_idea(note_id: int, reason: str) -> dict:
"""Demote a family idea — it no longer applies, or a better one replaces
it. Its history and every judged ledger answer are kept; rows nobody had
judged yet are closed. Undoable with undo_family_decision.
Args:
note_id: the idea.
reason: why it is retired.
"""
return await family_svc.retire(current_user_id(), note_id, reason=reason)
async def undo_family_decision(decision_id: int, reason: str) -> dict:
"""Reverse a family decision — a promotion, a retirement or a proposal —
restoring the state it recorded as `before`. Only the latest idea-level
decision on an idea can be undone; the undo is logged too, so the history
keeps both.
Args:
decision_id: the decision (list_family_decisions).
reason: why it is undone.
"""
return await family_svc.undo(current_user_id(), decision_id, reason=reason)
async def list_family_decisions(note_id: int = 0, limit: int = 50, offset: int = 0) -> dict:
"""The family decision log, newest first: every proposal, promotion,
veto, retirement and undo, with its reason, evidence and the precedents
it followed. `undoable` marks the decision an undo would reverse.
Args:
note_id: only this idea's decisions. 0 = all.
limit / offset: page through the log.
"""
rows = await family_svc.list_decisions(
current_user_id(), idea_id=note_id or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"decisions": rows, "limit": limit, "offset": offset}
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,
):
mcp.tool(name=fn.__name__)(fn)
+11 -2
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import dedup as dedup_svc
from scribe.services import family as family_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
from scribe.services import task_logs as task_logs_svc
@@ -172,12 +173,20 @@ async def update_milestone(
if order_index >= 0:
fields["order_index"] = order_index
await refuse_guessed_ids(title, description, body)
# Asked BEFORE the write: only the transition into done is a closing.
closing = status == "done" and await family_svc.milestone_is_open(uid, milestone_id)
milestone = await milestones_svc.update_milestone(uid, milestone_id, **fields)
if milestone is None:
raise ValueError(f"milestone {milestone_id} not found")
data = milestone.to_dict()
if closing:
# Family canon's milestone trigger (milestone 463): a plan closing on
# a platform is the moment to ask whether what it built is shared.
hint = await family_svc.milestone_trigger(uid, milestone)
if hint:
data["family_hint"] = hint
return await moment_delivery.attach_moment_rules(
uid, "update_milestone", {"status": status, "project_id": project_id},
milestone.to_dict(),
uid, "update_milestone", {"status": status, "project_id": project_id}, data,
)
+6
View File
@@ -17,6 +17,7 @@ from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import family as family_svc
from scribe.services import notes as notes_svc
from scribe.services import supersession as supersession_svc
from scribe.services import systems as systems_svc
@@ -237,6 +238,9 @@ async def create_note(
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
await supersession_svc.attach_relations(uid, note.id, data, hint=True)
data.update(dedup_svc.note_overlap_response(overlaps, "note"))
# Family canon's write-time triggers (milestone 463): a cited source of a
# pattern, or the same idea in another project on a shared platform.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_note", {"project_id": project_id}, data)
@@ -319,6 +323,8 @@ async def update_note(
uid, getattr(note, "user_id", uid) or uid, data, note_id, note.project_id
)
await supersession_svc.attach_relations(uid, note_id, data, hint=True)
if body:
await family_svc.attach_family_hint(uid, data, note, created=False)
return data
+4
View File
@@ -14,6 +14,7 @@ from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import family as family_svc
from scribe.services import snippets as snippets_svc
from scribe.services.note_usage import attach_usage, record_pulled
from scribe.services import systems as systems_svc
@@ -224,6 +225,9 @@ async def create_snippet(
advice = snippets_svc.trigger_advice(when_to_use)
if advice:
data["trigger_advice"] = advice
# Family canon (milestone 463): the same shape recorded in another project
# on a shared platform opens an evaluation of the earlier one.
await family_svc.attach_family_hint(uid, data, note, created=True)
return await moment_delivery.attach_moment_rules(uid, "create_snippet", {"project_id": project_id}, data)
+6
View File
@@ -28,6 +28,7 @@ from scribe.mcp._context import current_user_id
from scribe.mcp.tools import systems as systems_tools
from scribe.services import access as access_svc
from scribe.services import dedup as dedup_svc
from scribe.services import family as family_svc
from scribe.services import milestones as milestones_svc
from scribe.services import notes as notes_svc
# Imported by NAME, not reached through notes_svc: minted_kind is pure
@@ -363,6 +364,9 @@ async def create_task(
data = note.to_dict()
await systems_tools.attach_systems(uid, uid, data, note.id, project_id or None)
data.update(dedup_svc.note_overlap_response(overlaps, "task"))
# A task that says it is "matching" another project's work names the
# source of a pattern — family canon's citation trigger (milestone 463).
await family_svc.attach_family_hint(uid, data, note, created=True)
return await placement_svc.attach_placement(uid, data, note)
@@ -457,6 +461,8 @@ async def update_task(
uid, getattr(note, "user_id", uid) or uid, data, task_id, note.project_id
)
await placement_svc.attach_placement(uid, data, note)
if body:
await family_svc.attach_family_hint(uid, data, note, created=False)
if status in _CLOSING_STATUSES:
data["report_back"] = REPORT_BACK_CUE
# The operator's own adjustments to the completion report, retrieved
+125
View File
@@ -0,0 +1,125 @@
"""Family canon routes — the web door to the promotion engine (milestone 463).
The agent decides promotions through the MCP tools; this door exists so a
person can READ every decision and its reasons, and retire an idea or undo a
decision when they disagree. The promote and propose endpoints are here for
parity with the agent's door (rule 33), and are recorded as the operator's.
Every write is gated on the idea's note in the service (rule 78).
"""
import logging
from quart import Blueprint, jsonify, request
from scribe.auth import get_current_user_id, login_required
from scribe.routes.utils import not_found
from scribe.services import family as family_svc
logger = logging.getLogger(__name__)
family_bp = Blueprint("family", __name__, url_prefix="/api/family")
def _page() -> tuple[int, int]:
try:
limit = max(1, min(int(request.args.get("limit", 50)), 200))
offset = max(0, int(request.args.get("offset", 0)))
except ValueError:
limit, offset = 50, 0
return limit, offset
@family_bp.route("/ideas", methods=["GET"])
@login_required
async def list_ideas_route():
limit, offset = _page()
ideas = await family_svc.list_ideas(
get_current_user_id(),
status=request.args.get("status") or None,
platform=request.args.get("platform") or None,
limit=limit, offset=offset,
)
return jsonify({"ideas": ideas, "criteria": list(family_svc.CRITERIA)})
@family_bp.route("/ideas/<int:note_id>", methods=["GET"])
@login_required
async def get_idea_route(note_id: int):
uid = get_current_user_id()
idea = await family_svc.get_idea(uid, note_id)
if idea is None:
return not_found("Family idea")
idea["precedents"] = await family_svc.precedents(uid, note_id)
return jsonify(idea)
@family_bp.route("/ideas/<int:note_id>/propose", methods=["POST"])
@login_required
async def propose_route(note_id: int):
data = await request.get_json() or {}
try:
idea, created = await family_svc.propose(
get_current_user_id(), note_id, reason=data.get("reason") or "",
applies_when=data.get("applies_when") or None, decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify({"idea": idea, "created": created}), (201 if created else 200)
@family_bp.route("/ideas/<int:note_id>/promote", methods=["POST"])
@login_required
async def promote_route(note_id: int):
data = await request.get_json() or {}
try:
result = await family_svc.promote(
get_current_user_id(), note_id,
applies_when=data.get("applies_when") or "",
platforms=data.get("platforms") or [],
criteria=data.get("criteria") or {},
evidence=data.get("evidence") or [],
reason=data.get("reason") or "",
precedent_ids=data.get("precedent_ids") or [],
decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result)
@family_bp.route("/ideas/<int:note_id>/retire", methods=["POST"])
@login_required
async def retire_route(note_id: int):
data = await request.get_json() or {}
try:
result = await family_svc.retire(
get_current_user_id(), note_id, reason=data.get("reason") or "",
decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result)
@family_bp.route("/decisions", methods=["GET"])
@login_required
async def list_decisions_route():
limit, offset = _page()
idea_id = request.args.get("idea_id", type=int)
rows = await family_svc.list_decisions(
get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset,
)
return jsonify({"decisions": rows, "limit": limit, "offset": offset})
@family_bp.route("/decisions/<int:decision_id>/undo", methods=["POST"])
@login_required
async def undo_route(decision_id: int):
data = await request.get_json() or {}
try:
result = await family_svc.undo(
get_current_user_id(), decision_id, reason=data.get("reason") or "",
decided_via="operator",
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(result)
+838
View File
@@ -0,0 +1,838 @@
"""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.services import access
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 _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),
"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),
})
out.append(d)
return out
async def list_decisions(
user_id: int, *, idea_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."""
async with async_session() as session:
query = (
select(FamilyDecision, Note.title)
.join(Note, Note.id == FamilyDecision.idea_id)
.where(access.readable_notes_clause(user_id))
)
if idea_id:
query = query.where(FamilyDecision.idea_id == idea_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})
out = []
for d, title in rows:
item = _decision_dict(d, title)
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
out.append(item)
return out
def _can_undo(d: FamilyDecision) -> bool:
"""An idea-level decision that changed something. A veto (a `propose`
whose before and after agree) changed nothing, and an undo is undone by
deciding again, not by undoing the undo."""
return d.project_id is None and d.action in _IDEA_ACTIONS and d.before != d.after
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_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 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."""
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 []
ranked = [(score, n) for score, n in hits if n.id in idea_ids][: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.
history = (await session.execute(
select(FamilyDecision.action, FamilyDecision.after)
.where(FamilyDecision.idea_id == note_id)
)).all()
if any(action == "promote" for action, _ in history):
seen = [idea.canon_version or 1] + [
int(after.get("canon_version") or 1) for _, after in history if after
]
idea.canon_version = max(seen) + 1
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 _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 an idea-level decision, restoring the state it recorded as
`before`. 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 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)
prior = target.before or {
"status": "retired", "applies_when": idea.applies_when or "",
"canon_version": idea.canon_version, "platforms": before["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)