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
+78
View File
@@ -0,0 +1,78 @@
/**
* Family canon (milestone 463): ideas every project on a platform shares, and
* the log of every decision about them.
*
* The agent promotes by written criteria — no approval step. This door is for
* reading what was decided and why, and for retiring an idea or undoing a
* decision when a person disagrees.
*/
import { apiGet, apiPost } from "@/api/client";
export type IdeaStatus = "candidate" | "canon" | "retired";
export type DecisionAction = "propose" | "promote" | "revise" | "retire" | "assess" | "undo";
export interface Criterion {
key: string;
title: string;
test: string;
}
export interface FamilyIdea {
note_id: number;
status: IdeaStatus;
applies_when: string;
canon_version: number;
topic_id: number | null;
title: string;
note_type: string;
is_task: boolean;
project_id: number | null;
platforms: string[];
created_at: string | null;
updated_at: string | null;
}
/** A state snapshot a decision records — slugs, never ids. */
export interface IdeaSnapshot {
status: IdeaStatus;
applies_when: string;
canon_version: number;
platforms: string[];
}
export interface FamilyDecision {
id: number;
idea_id: number;
idea_title?: string;
project_id: number | null;
action: DecisionAction;
reason: string;
before: IdeaSnapshot | null;
after: IdeaSnapshot | null;
evidence: Record<string, unknown>;
precedent_ids: number[];
decided_via: "agent" | "operator" | "system";
user_id: number | null;
created_at: string | null;
undoable?: boolean;
}
export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> {
const q = status ? `?status=${encodeURIComponent(status)}` : "";
return apiGet(`/api/family/ideas${q}`);
}
export async function listFamilyDecisions(limit = 50, offset = 0): Promise<FamilyDecision[]> {
const data = await apiGet<{ decisions: FamilyDecision[] }>(
`/api/family/decisions?limit=${limit}&offset=${offset}`,
);
return data.decisions;
}
export async function undoFamilyDecision(id: number, reason: string) {
return apiPost<{ decision: FamilyDecision }>(`/api/family/decisions/${id}/undo`, { reason });
}
export async function retireFamilyIdea(noteId: number, reason: string) {
return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason });
}
+2
View File
@@ -50,6 +50,7 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<!-- A design system is a RECORD you author, not a setting. It sat in
the utility cluster with Trash and Settings while /design was a
read-only gallery, and stayed there after it became a record type
@@ -102,6 +103,7 @@ router.afterEach(() => {
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/snippets" class="nav-link">Snippets</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/family" class="nav-link">Family</router-link>
<router-link to="/design-systems" class="nav-link">Design</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
@@ -99,6 +99,7 @@ watch(() => props.projectId, load);
What this project is built on or ships as. Ideas the family has proven
for a platform reach every project that is one, and each project
answers them — adopts, varies with a reason, or owes the work.
<router-link to="/family">The family's ideas and decisions</router-link>
</p>
</header>
+7
View File
@@ -128,6 +128,13 @@ const router = createRouter({
name: "rules",
component: () => import("@/views/RulesView.vue"),
},
{
// Family canon (milestone 463): ideas shared across every project on a
// platform, and the log of every decision about them.
path: "/family",
name: "family",
component: () => import("@/views/FamilyView.vue"),
},
{
// The design systems this install RECORDS — for the projects it tracks,
// not for the install itself. There was a sibling `/design` that read the
+9
View File
@@ -0,0 +1,9 @@
/** Where a record opens. A task, a snippet, a lesson and a note live at
* different routes, and a link that guesses wrong is worse than one that is
* plain. One copy, for every list that links a record of any kind. */
export function recordHref(rec: { id: number; is_task?: boolean; note_type?: string }): string {
if (rec.is_task) return `/tasks/${rec.id}`;
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
return `/notes/${rec.id}`;
}
+360
View File
@@ -0,0 +1,360 @@
<script setup lang="ts">
/**
* Family canon (milestone 463): the ideas every project on a platform shares,
* and the log of every decision about them.
*
* Nobody approves a promotion — the agent decides against the three criteria
* shown at the top, and records why. This page is where a person reads those
* decisions, and the two places they can disagree: retire an idea, or undo
* the latest decision on one. Both ask for a reason, because the reason is
* what the next decision about something similar is checked against.
*/
import { computed, onMounted, ref } from "vue";
import { apiErrorMessage } from "@/api/client";
import {
listFamilyDecisions, listFamilyIdeas, retireFamilyIdea, undoFamilyDecision,
type Criterion, type FamilyDecision, type FamilyIdea, type IdeaStatus,
} from "@/api/family";
import { useToastStore } from "@/stores/toast";
import { fmtStamp } from "@/utils/dateFormat";
import { recordHref } from "@/utils/recordHref";
const PAGE = 50;
const toast = useToastStore();
const criteria = ref<Criterion[]>([]);
const ideas = ref<FamilyIdea[]>([]);
const decisions = ref<FamilyDecision[]>([]);
const statusFilter = ref<"" | IdeaStatus>("");
const loading = ref(true);
const failed = ref(false);
const moreDecisions = ref(false);
const loadingMore = ref(false);
// One pending reversal at a time: which row, which act, and its reason.
const pending = ref<{ kind: "undo" | "retire"; id: number } | null>(null);
const pendingReason = ref("");
const saving = ref(false);
const ACTION_LABELS: Record<string, string> = {
propose: "Proposed",
promote: "Promoted",
revise: "Revised",
retire: "Retired",
assess: "Assessed",
undo: "Undid",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [ideaData, log] = await Promise.all([
listFamilyIdeas(statusFilter.value),
listFamilyDecisions(PAGE, 0),
]);
ideas.value = ideaData.ideas;
criteria.value = ideaData.criteria;
decisions.value = log;
moreDecisions.value = log.length === PAGE;
} catch {
// Said out loud: a log that failed to load must not read as "nothing
// has been decided".
failed.value = true;
} finally {
loading.value = false;
}
}
async function loadMore() {
loadingMore.value = true;
try {
const next = await listFamilyDecisions(PAGE, decisions.value.length);
decisions.value.push(...next);
moreDecisions.value = next.length === PAGE;
} catch (e: unknown) {
toast.show(apiErrorMessage(e, "Could not load more decisions"), "error");
} finally {
loadingMore.value = false;
}
}
async function filterIdeas(value: "" | IdeaStatus) {
statusFilter.value = value;
try {
ideas.value = (await listFamilyIdeas(value)).ideas;
} catch (e: unknown) {
toast.show(apiErrorMessage(e, "Could not load ideas"), "error");
}
}
function startPending(kind: "undo" | "retire", id: number) {
pending.value = { kind, id };
pendingReason.value = "";
}
async function confirmPending() {
const p = pending.value;
const reason = pendingReason.value.trim();
if (!p || !reason || saving.value) return;
saving.value = true;
try {
if (p.kind === "undo") await undoFamilyDecision(p.id, reason);
else await retireFamilyIdea(p.id, reason);
toast.show(p.kind === "undo" ? "Decision undone" : "Idea retired");
pending.value = null;
await load();
} catch (e: unknown) {
toast.show(apiErrorMessage(e, p.kind === "undo" ? "Could not undo" : "Could not retire"), "error");
} finally {
saving.value = false;
}
}
const ideaById = computed(() => {
const out: Record<number, FamilyIdea> = {};
for (const i of ideas.value) out[i.note_id] = i;
return out;
});
function ideaLink(d: FamilyDecision): string {
const idea = ideaById.value[d.idea_id];
return idea ? recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })
: `/notes/${d.idea_id}`;
}
/** The parts of a decision's evidence worth reading, as label → lines. */
function evidenceLines(d: FamilyDecision): { label: string; lines: string[] }[] {
const ev = d.evidence || {};
const out: { label: string; lines: string[] }[] = [];
const criteriaReasons = ev.criteria as Record<string, string> | undefined;
if (criteriaReasons) {
const lines = criteria.value
.map((c) => `${c.title}: ${criteriaReasons[c.key] || "— not supported"}`);
out.push({ label: "Criteria", lines });
}
if (Array.isArray(ev.vetoed_by) && ev.vetoed_by.length) {
const titles = (ev.vetoed_by as string[])
.map((k) => criteria.value.find((c) => c.key === k)?.title ?? k);
out.push({ label: "Held back by", lines: titles });
}
if (Array.isArray(ev.evidence) && ev.evidence.length) {
out.push({ label: "Evidence", lines: ev.evidence as string[] });
}
if (typeof ev.trigger === "string") {
out.push({ label: "Trigger", lines: [ev.trigger] });
}
return out;
}
function stateLine(d: FamilyDecision): string {
const a = d.after;
if (!a) return "";
const where = a.platforms.length ? ` · ${a.platforms.join(", ")}` : "";
return `${a.status} v${a.canon_version}${where}`;
}
onMounted(load);
</script>
<template>
<main class="page-container">
<div class="page-header">
<h1>Family</h1>
</div>
<p class="fam-lede">
Ideas every project on a platform shares — the stance, the shape and the holes
already found, not the code. The agent promotes an idea when all three criteria
hold, with no approval step; every decision and its reasons are below, and any
of them can be undone.
</p>
<p v-if="loading" class="fam-muted">Loading…</p>
<div v-else-if="failed" class="fam-note">
<strong>The family record couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty record.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<template v-else>
<section class="fam-section" aria-labelledby="fam-criteria">
<h2 id="fam-criteria">What gets promoted</h2>
<ol class="fam-criteria">
<li v-for="c in criteria" :key="c.key">
<strong>{{ c.title }}.</strong> {{ c.test }}
</li>
</ol>
</section>
<section class="fam-section" aria-labelledby="fam-ideas">
<div class="fam-section-head">
<h2 id="fam-ideas">Ideas</h2>
<div class="fam-filter" role="group" aria-label="Filter ideas by state">
<button
v-for="opt in (['', 'candidate', 'canon', 'retired'] as const)"
:key="opt"
:class="['btn-ghost', 'btn-sm', { active: statusFilter === opt }]"
:aria-pressed="statusFilter === opt"
@click="filterIdeas(opt)"
>{{ opt || "All" }}</button>
</div>
</div>
<p v-if="!ideas.length" class="fam-muted">
<template v-if="statusFilter">No {{ statusFilter }} ideas.</template>
<template v-else>
No family ideas yet. One appears when a record cites another project's
work as its source, when two projects on a platform build the same thing,
or when the agent proposes one.
</template>
</p>
<ul v-else class="fam-list">
<li v-for="idea in ideas" :key="idea.note_id" class="fam-row">
<div class="fam-row-main">
<div class="fam-row-title">
<router-link :to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })">
{{ idea.title }}
</router-link>
<span :class="['fam-status', `fam-${idea.status}`]">{{ idea.status }}</span>
<span v-if="idea.status !== 'candidate'" class="fam-muted">v{{ idea.canon_version }}</span>
</div>
<p v-if="idea.applies_when" class="fam-applies">Applies when: {{ idea.applies_when }}</p>
<div v-if="idea.platforms.length" class="fam-platforms">
<code v-for="p in idea.platforms" :key="p" class="fam-chip">{{ p }}</code>
</div>
</div>
<button
v-if="idea.status !== 'retired'"
class="btn-ghost btn-sm"
@click="startPending('retire', idea.note_id)"
>Retire</button>
<form
v-if="pending?.kind === 'retire' && pending.id === idea.note_id"
class="fam-reason"
@submit.prevent="confirmPending"
>
<input
v-model="pendingReason"
class="fs-input"
placeholder="Why retire it? The next decision is checked against this."
aria-label="Reason for retiring"
/>
<button type="submit" class="btn-danger btn-sm" :disabled="!pendingReason.trim() || saving">Retire</button>
<button type="button" class="btn-ghost btn-sm" @click="pending = null">Cancel</button>
</form>
</li>
</ul>
</section>
<section class="fam-section" aria-labelledby="fam-log">
<h2 id="fam-log">Decision log</h2>
<p v-if="!decisions.length" class="fam-muted">Nothing has been decided yet.</p>
<ul v-else class="fam-list">
<li v-for="d in decisions" :key="d.id" class="fam-row fam-decision">
<div class="fam-row-main">
<div class="fam-row-title">
<span class="fam-action">{{ ACTION_LABELS[d.action] ?? d.action }}</span>
<router-link :to="ideaLink(d)">{{ d.idea_title ?? `#${d.idea_id}` }}</router-link>
<span class="fam-muted">
#{{ d.id }} · {{ d.decided_via }} · {{ d.created_at ? fmtStamp(d.created_at) : "" }}
</span>
</div>
<p class="fam-reason-text">{{ d.reason }}</p>
<p v-if="stateLine(d)" class="fam-muted">Now: {{ stateLine(d) }}</p>
<dl v-for="part in evidenceLines(d)" :key="part.label" class="fam-evidence">
<dt>{{ part.label }}</dt>
<dd v-for="line in part.lines" :key="line">{{ line }}</dd>
</dl>
<p v-if="d.precedent_ids.length" class="fam-muted">
Consistent with decision{{ d.precedent_ids.length === 1 ? "" : "s" }}
{{ d.precedent_ids.map((p) => `#${p}`).join(", ") }}
</p>
</div>
<button
v-if="d.undoable"
class="btn-ghost btn-sm"
@click="startPending('undo', d.id)"
>Undo</button>
<form
v-if="pending?.kind === 'undo' && pending.id === d.id"
class="fam-reason"
@submit.prevent="confirmPending"
>
<input
v-model="pendingReason"
class="fs-input"
placeholder="Why undo it? The next decision is checked against this."
aria-label="Reason for undoing"
/>
<button type="submit" class="btn-danger btn-sm" :disabled="!pendingReason.trim() || saving">Undo</button>
<button type="button" class="btn-ghost btn-sm" @click="pending = null">Cancel</button>
</form>
</li>
</ul>
<button v-if="moreDecisions" class="btn-secondary btn-sm" :disabled="loadingMore" @click="loadMore">
{{ loadingMore ? "Loading…" : "Older decisions" }}
</button>
</section>
</template>
</main>
</template>
<style scoped>
.fam-lede { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 1.5rem; max-width: 52rem; }
.fam-muted { color: var(--fs-text-tertiary); font-size: 0.85rem; margin: 0; }
.fam-note {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.85rem 1rem;
}
.fam-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
.fam-section { margin-bottom: 2rem; }
.fam-section h2 { font-size: 1.05rem; margin: 0 0 0.6rem; }
.fam-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.fam-filter { display: flex; gap: 0.25rem; }
.fam-filter .active { color: var(--fs-text-primary); border-color: var(--fs-border-color); }
.fam-criteria { margin: 0; padding-left: 1.25rem; color: var(--fs-text-secondary); font-size: 0.9rem; }
.fam-criteria li { margin-bottom: 0.35rem; }
.fam-list { list-style: none; margin: 0 0 0.75rem; padding: 0; }
.fam-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--fs-border-color);
}
.fam-row-main { flex: 1; min-width: 0; }
.fam-row-title { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; }
.fam-applies { margin: 0.3rem 0 0; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-platforms { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.35rem; }
.fam-chip {
font-family: var(--fs-font-mono);
font-size: 0.72rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-code-inline);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.35rem;
}
.fam-status {
font-size: 0.72rem;
padding: 0.05rem 0.45rem;
border-radius: var(--fs-radius-pill);
border: 1px solid var(--fs-border-color);
}
.fam-candidate { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
.fam-canon { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.fam-retired { color: var(--fs-status-cancelled-fg); }
.fam-action { font-weight: 500; color: var(--fs-text-primary); }
.fam-reason-text { margin: 0.3rem 0; font-size: 0.9rem; color: var(--fs-text-primary); }
.fam-evidence { margin: 0.35rem 0 0; font-size: 0.82rem; }
.fam-evidence dt { color: var(--fs-text-tertiary); }
.fam-evidence dd { margin: 0 0 0 1rem; color: var(--fs-text-secondary); }
.fam-reason { flex-basis: 100%; display: flex; gap: 0.4rem; align-items: center; }
.fam-reason .fs-input { flex: 1; min-width: 0; }
@media (max-width: 640px) {
.fam-reason { flex-wrap: wrap; }
}
</style>
+2 -9
View File
@@ -41,6 +41,7 @@ import { DEAD_WEIGHT_ADVICE } from "@/utils/deadWeight";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { canWriteRecord } from "@/utils/permission";
import { recordHref } from "@/utils/recordHref";
const route = useRoute();
const router = useRouter();
@@ -59,14 +60,6 @@ const insightHtml = computed(() =>
lesson.value?.insight ? renderMarkdown(lesson.value.insight) : "",
);
/** Where each source opens. A task and a note live at different routes, and a
* link that guesses wrong is worse than one that is plain. */
function sourceHref(rec: { id: number; is_task?: boolean; note_type?: string }) {
if (rec.is_task) return `/tasks/${rec.id}`;
if (rec.note_type === "snippet") return `/snippets/${rec.id}`;
if (rec.note_type === "lesson") return `/lessons/${rec.id}`;
return `/notes/${rec.id}`;
}
const canWrite = computed(() => canWriteRecord(lesson.value?.permission));
@@ -193,7 +186,7 @@ onMounted(load);
<h2 class="ld-panel-label">Learned from</h2>
<ul class="ld-sources">
<li v-for="rec in lesson.learned_from_records" :key="rec.id">
<router-link :to="sourceHref(rec)" class="ld-link">
<router-link :to="recordHref(rec)" class="ld-link">
{{ rec.title }}
</router-link>
<span v-if="rec.status" class="ld-source-status">{{ rec.status }}</span>
+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)
+24
View File
@@ -215,6 +215,30 @@ def _no_lesson_rule_links(request):
yield
@pytest.fixture(autouse=True)
def _no_family_triggers(request):
"""Stub family canon's write-time triggers (milestone 463 step 3).
create_note, create_task, create_snippet, the updates that change a body,
and update_milestone(status="done") now look for a pattern carried between
projects, and each look is a database read (the repeat check also embeds).
All are fail-open, so unstubbed they would cost a slow failed connect per
unit test rather than a failure. Patched at the trigger functions, beneath
`attach_family_hint`, so the attach itself — its order and its fail-open —
still runs. tests/test_family.py binds the real pure parts; the triggers
themselves are exercised against Postgres in
tests/test_integration_family_promotion.py, which this skips.
"""
if request.node.get_closest_marker("integration"):
yield
return
with patch("scribe.services.family.citation_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.repeat_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.milestone_trigger", AsyncMock(return_value=None)), \
patch("scribe.services.family.milestone_is_open", AsyncMock(return_value=False)):
yield
@pytest.fixture(autouse=True)
def _no_moment_delivery(request):
"""Stub the moment lookup the mapped MCP tools attach (milestone 458).
+163
View File
@@ -0,0 +1,163 @@
"""The promotion engine without a database (milestone 463 step 3).
The parts that decide — which citations claim lineage, which criteria veto —
are pure and pinned here, beside the doors' wiring: the triggers ride the
write tools, fail open, and the criteria the service enforces are the ones
the agent is told. The state machine against Postgres is in
tests/test_integration_family_promotion.py.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.mcp.tools import family as family_tools
from scribe.mcp.tools.milestones import update_milestone
from scribe.services import family as family_svc
from scribe.services.family import CRITERIA_KEYS, lineage_citations, vetoes
from tests.helpers import fake_milestone
pytestmark = pytest.mark.usefixtures("_bind_user")
# --- which citations claim lineage ---------------------------------------------
@pytest.mark.parametrize("text", [
"Signed release lane, matching roundtable-android (task #1615).",
"Ported from #1615, with the keystore step moved first.",
"Same shape as #1615: the APK is baked into the image.",
"This mirrors #1615 for the desktop client.",
"Modelled on #1615.",
])
def test_a_citation_with_a_lineage_word_names_its_source(text):
assert lineage_citations(text) == [1615]
@pytest.mark.parametrize("text", [
"See #1615 for context.",
"#1615 is related.",
"Closes #1615.",
# The lineage word is in the PREVIOUS sentence — a different claim.
"This is matching the old flow. Unrelated: #1615.",
"Matching the old flow\nsee #1615",
"",
])
def test_a_citation_without_lineage_is_silent(text):
assert lineage_citations(text) == []
def test_lineage_citations_are_in_order_and_unique():
text = "Based on #20; ported from #10. Same pattern as #20."
assert lineage_citations(text) == [20, 10]
# --- each criterion vetoes on its own -----------------------------------------------
GOOD = dict(
applies_when="any app that installs its own updates",
platforms=["android-app"],
criteria={
"platform_terms": "stated as an Android distribution concern",
"platform_problem": "Play Protect and signature checks are the platform's",
"proven": "shipped in two apps",
},
evidence=["CI run 8274 green", "#4775"],
)
def test_all_three_criteria_held_means_no_veto():
assert vetoes(**GOOD) == []
@pytest.mark.parametrize("key", CRITERIA_KEYS)
def test_each_criterion_left_unsupported_vetoes_on_its_own(key):
case = {**GOOD, "criteria": {**GOOD["criteria"], key: " "}}
assert vetoes(**case) == [key]
def test_platform_terms_needs_an_applicability_test_and_a_scope():
assert vetoes(**{**GOOD, "applies_when": ""}) == ["platform_terms"]
assert vetoes(**{**GOOD, "platforms": []}) == ["platform_terms"]
def test_proven_needs_named_evidence():
assert vetoes(**{**GOOD, "evidence": []}) == ["proven"]
assert vetoes(**{**GOOD, "evidence": [" "]}) == ["proven"]
def test_the_criteria_the_agent_is_told_are_the_ones_enforced():
"""Rule 119: the criteria are product text. The promote tool's docstring
must name every criterion the service checks, by its parameter name."""
doc = family_tools.promote_family_idea.__doc__
for key in CRITERIA_KEYS:
assert key in doc, f"promote_family_idea's docstring never names {key}"
assert len(family_svc.CRITERIA) == 3
# --- the doors ----------------------------------------------------------------------
def test_every_family_tool_is_classified():
reads = {"list_family_ideas", "get_family_idea", "list_family_decisions"}
writes = {"propose_family_idea", "promote_family_idea", "retire_family_idea",
"undo_family_decision"}
assert reads <= _READ_ONLY_TOOLS
assert writes <= _WRITE_TOOLS
def _note(**kw):
base = dict(id=7, project_id=2, title="t", body="b", is_task=False)
return SimpleNamespace(**{**base, **kw})
async def test_a_citation_hint_wins_and_repeat_is_not_asked():
data: dict = {}
repeat = AsyncMock(return_value="repeat")
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value="cited")), \
patch.object(family_svc, "repeat_trigger", repeat):
await family_svc.attach_family_hint(1, data, _note(), created=True)
assert data == {"family_hint": "cited"}
repeat.assert_not_awaited()
async def test_the_repeat_check_runs_only_on_a_create():
repeat = AsyncMock(return_value="repeat")
with patch.object(family_svc, "citation_trigger", AsyncMock(return_value=None)), \
patch.object(family_svc, "repeat_trigger", repeat):
edited: dict = {}
await family_svc.attach_family_hint(1, edited, _note(), created=False)
created: dict = {}
await family_svc.attach_family_hint(1, created, _note(), created=True)
assert edited == {}
assert created == {"family_hint": "repeat"}
async def test_a_failing_trigger_never_breaks_the_write():
data: dict = {"id": 7}
with patch.object(family_svc, "citation_trigger", AsyncMock(side_effect=RuntimeError("db down"))):
await family_svc.attach_family_hint(1, data, _note(), created=True)
assert data == {"id": 7}
async def test_closing_a_milestone_on_a_platform_carries_the_hint():
closed = fake_milestone(id=5, project_id=3, status="done")
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
AsyncMock(return_value=closed)), \
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=True)), \
patch.object(family_svc, "milestone_trigger", AsyncMock(return_value="evaluate")):
out = await update_milestone(project_id=3, milestone_id=5, status="done")
assert out["family_hint"] == "evaluate"
async def test_re_saving_a_closed_milestone_asks_nothing():
closed = fake_milestone(id=5, project_id=3, status="done")
trigger = AsyncMock(return_value="evaluate")
with patch("scribe.mcp.tools.milestones.milestones_svc.update_milestone",
AsyncMock(return_value=closed)), \
patch.object(family_svc, "milestone_is_open", AsyncMock(return_value=False)), \
patch.object(family_svc, "milestone_trigger", trigger):
out = await update_milestone(project_id=3, milestone_id=5, status="done")
assert "family_hint" not in out
trigger.assert_not_awaited()
+293
View File
@@ -0,0 +1,293 @@
"""The promotion engine against real Postgres (milestone 463 step 3).
What the unit lane can't show: a promotion opens the right ledger rows and
no others, a veto changes nothing but the log, an undo puts back exactly the
recorded prior state, and each trigger fires on its fixture and stays silent
on the near-miss beside it.
Precedent search and the repeat trigger both rank by meaning, and the lane
has no embedding model. The search is stubbed to "nothing similar" for every
test here (`_no_meaning`); the repeat tests stub it to one hit, to pin what
the trigger does WITH a hit. The ranking itself is the shared semantic
search, tested where that lives.
"""
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform,
)
from scribe.models.milestone import Milestone
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.user import User
from scribe.services import family as family_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER = "family_promotion_owner"
OUTSIDER = "family_promotion_outsider"
CRITERIA = {
"platform_terms": "stated for any Android app that distributes its own APK",
"platform_problem": "signature continuity is the platform's rule, not one app's",
"proven": "shipped and updated in place on a device",
}
async def _purge(username: str) -> None:
"""SETUP ONLY, as the backup round-trip siblings do: a database call
after a `yield` in an autouse fixture orphans a pooled connection."""
async with async_session() as s:
for user in (await s.execute(select(User).where(User.username == username))).scalars():
for note in (await s.execute(select(Note).where(Note.user_id == user.id))).scalars():
await s.delete(note)
for project in (await s.execute(select(Project).where(Project.user_id == user.id))).scalars():
await s.delete(project)
await s.commit()
@pytest_asyncio.fixture(autouse=True)
async def _clean():
await _purge(OWNER)
await _purge(OUTSIDER)
@pytest.fixture(autouse=True)
def _no_meaning():
with patch("scribe.services.embeddings.semantic_search_notes", AsyncMock(return_value=[])):
yield
async def _platform_id(slug: str) -> int:
async with async_session() as s:
return await s.scalar(select(Platform.id).where(
Platform.slug == slug, Platform.deleted_at.is_(None)))
@pytest_asyncio.fixture
async def family():
"""Three projects: two Android apps and a Go service, the pattern note
in the first, and an outsider's Android app the owner cannot write."""
android, go = await _platform_id("android-app"), await _platform_id("go")
async with async_session() as s:
owner = await ensure_user(s, OWNER)
outsider = await ensure_user(s, OUTSIDER)
a = Project(user_id=owner.id, title="android one")
b = Project(user_id=owner.id, title="android two")
c = Project(user_id=owner.id, title="go service")
theirs = Project(user_id=outsider.id, title="someone else's android app")
s.add_all([a, b, c, theirs])
await s.flush()
s.add_all([
ProjectPlatform(project_id=a.id, platform_id=android, state="declared"),
ProjectPlatform(project_id=b.id, platform_id=android, state="detected"),
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
ProjectPlatform(project_id=theirs.id, platform_id=android, state="declared"),
])
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
body="One keystore, two channels, in-place update.")
s.add(pattern)
await s.commit()
return {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id,
"c": c.id, "theirs": theirs.id, "note": pattern.id}
async def _promote(f, **overrides):
kw = dict(
applies_when="any Android app that ships its own APK",
platforms=["android-app"], criteria=CRITERIA,
evidence=["CI green on the release lane", "verified on a device"],
reason="built twice, proven, stated for the platform",
)
kw.update(overrides)
return await family_svc.promote(f["owner"], f["note"], **kw)
async def _ledger(note_id: int) -> dict[int, str]:
async with async_session() as s:
rows = (await s.execute(
select(FamilyAdoption.project_id, FamilyAdoption.status)
.where(FamilyAdoption.idea_id == note_id)
)).all()
return dict(rows)
async def _idea(note_id: int) -> FamilyIdea:
async with async_session() as s:
return await s.get(FamilyIdea, note_id)
# --- promotion ------------------------------------------------------------------
async def test_promotion_opens_a_row_for_every_member_project_the_promoter_can_write(family):
out = await _promote(family)
assert out["promoted"] is True
# Declared and detected both count as membership; the Go service is not
# on the platform; the outsider's app is not the promoter's to write.
assert await _ledger(family["note"]) == {family["a"]: "unassessed",
family["b"]: "unassessed"}
idea = await _idea(family["note"])
assert idea.status == "canon" and idea.canon_version == 1
decision = out["decision"]
assert decision["action"] == "promote"
assert decision["after"]["platforms"] == ["android-app"]
assert decision["evidence"]["criteria"]["proven"] == CRITERIA["proven"]
assert decision["evidence"]["ledger_rows_opened"] == 2
@pytest.mark.parametrize("key", list(family_svc.CRITERIA_KEYS))
async def test_each_criterion_vetoes_on_its_own_and_the_veto_is_logged(family, key):
out = await _promote(family, criteria={**CRITERIA, key: ""})
assert out["promoted"] is False and out["vetoed_by"] == [key]
assert (await _idea(family["note"])).status == "candidate"
assert await _ledger(family["note"]) == {}
assert out["decision"]["action"] == "propose"
assert out["decision"]["evidence"]["vetoed_by"] == [key]
async def test_an_unknown_platform_is_refused_before_anything_is_written(family):
with pytest.raises(ValueError, match="unknown platform"):
await _promote(family, platforms=["android-app", "no-such-platform"])
assert await _idea(family["note"]) is None
async def test_someone_who_cannot_write_the_note_cannot_promote_it(family):
with pytest.raises(ValueError, match="no write access"):
await family_svc.promote(
family["outsider"], family["note"], applies_when="x", platforms=["android-app"],
criteria=CRITERIA, evidence=["e"], reason="r",
)
# --- undo and retirement ------------------------------------------------------------
async def test_undoing_a_promotion_restores_the_prior_state_and_keeps_judged_rows(family):
out = await _promote(family)
async with async_session() as s:
row = (await s.execute(select(FamilyAdoption).where(
FamilyAdoption.idea_id == family["note"],
FamilyAdoption.project_id == family["a"]))).scalars().one()
row.status, row.canon_version, row.decided_via = "adopted", 1, "agent"
await s.commit()
await family_svc.undo(family["owner"], out["decision"]["id"], reason="promoted too early")
idea = await _idea(family["note"])
# Promoted directly, with no proposal before it: the prior state was "not
# an idea", which an undo records as retired rather than deleting the
# idea and its log with it.
assert idea.status == "retired" and idea.applies_when is None
# The unjudged row goes; the judged one stays as history.
assert await _ledger(family["note"]) == {family["a"]: "adopted"}
again = await _promote(family)
# Re-promotion moves PAST every version this idea has held, so the kept
# answer reads as needing a recheck rather than as still agreeing.
assert again["idea"]["canon_version"] == 2
assert await _ledger(family["note"]) == {family["a"]: "adopted", family["b"]: "unassessed"}
async def test_retiring_closes_unjudged_rows_and_undoing_it_reopens_them(family):
await _promote(family)
out = await family_svc.retire(family["owner"], family["note"], reason="superseded")
assert (await _idea(family["note"])).status == "retired"
assert await _ledger(family["note"]) == {}
await family_svc.undo(family["owner"], out["decision"]["id"], reason="not superseded after all")
assert (await _idea(family["note"])).status == "canon"
assert set((await _ledger(family["note"])).values()) == {"unassessed"}
async def test_only_the_latest_idea_decision_can_be_undone(family):
first = await _promote(family)
await family_svc.retire(family["owner"], family["note"], reason="superseded")
with pytest.raises(ValueError, match="came after it"):
await family_svc.undo(family["owner"], first["decision"]["id"], reason="r")
async def test_an_undo_names_what_it_reversed_as_its_precedent(family):
out = await _promote(family)
undo = await family_svc.undo(family["owner"], out["decision"]["id"], reason="r")
assert undo["decision"]["action"] == "undo"
assert undo["decision"]["precedent_ids"] == [out["decision"]["id"]]
async with async_session() as s:
actions = (await s.execute(select(FamilyDecision.action).where(
FamilyDecision.idea_id == family["note"]).order_by(FamilyDecision.id))).scalars().all()
assert actions == ["promote", "undo"]
# --- triggers -------------------------------------------------------------------------
async def _note_in(project_id: int, owner_id: int, body: str) -> Note:
async with async_session() as s:
note = Note(user_id=owner_id, project_id=project_id, title="the second build", body=body)
s.add(note)
await s.commit()
await s.refresh(note)
return note
async def test_a_cross_project_lineage_citation_opens_an_evaluation(family):
citing = await _note_in(family["b"], family["owner"],
f"Release lane, matching the first app (#{family['note']}).")
hint = await family_svc.citation_trigger(family["owner"], citing)
assert hint and f"#{family['note']}" in hint
idea = await _idea(family["note"])
assert idea is not None and idea.status == "candidate"
async with async_session() as s:
d = (await s.execute(select(FamilyDecision).where(
FamilyDecision.idea_id == family["note"]))).scalars().one()
assert d.decided_via == "system" and d.evidence["trigger"] == "citation"
async def test_a_citation_without_lineage_or_within_one_project_is_silent(family):
pointer = await _note_in(family["b"], family["owner"], f"See #{family['note']} for context.")
same_project = await _note_in(family["a"], family["owner"], f"Matching #{family['note']}.")
assert await family_svc.citation_trigger(family["owner"], pointer) is None
assert await family_svc.citation_trigger(family["owner"], same_project) is None
assert await _idea(family["note"]) is None
async def test_a_repeat_on_a_shared_platform_opens_an_evaluation(family):
"""The meaning match is stubbed — the lane has no model — so this pins
what the trigger does with a hit: only a hit in ANOTHER project that
shares a platform counts."""
repeat = await _note_in(family["b"], family["owner"], "One keystore, two channels.")
async with async_session() as s:
original = await s.get(Note, family["note"])
with patch("scribe.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.86, original)])):
hint = await family_svc.repeat_trigger(family["owner"], repeat)
assert hint and f"#{family['note']}" in hint
assert (await _idea(family["note"])).status == "candidate"
async def test_a_repeat_with_no_shared_platform_is_silent(family):
unrelated = await _note_in(family["c"], family["owner"], "One keystore, two channels.")
async with async_session() as s:
original = await s.get(Note, family["note"])
with patch("scribe.services.embeddings.semantic_search_notes",
AsyncMock(return_value=[(0.86, original)])):
assert await family_svc.repeat_trigger(family["owner"], unrelated) is None
assert await _idea(family["note"]) is None
async def test_a_milestone_closing_on_a_platform_asks_and_one_off_platform_does_not(family):
async with async_session() as s:
on_platform = Milestone(user_id=family["owner"], project_id=family["a"], title="m1")
bare = Project(user_id=family["owner"], title="no platforms")
s.add_all([on_platform, bare])
await s.flush()
off_platform = Milestone(user_id=family["owner"], project_id=bare.id, title="m2")
s.add(off_platform)
await s.commit()
await s.refresh(on_platform)
await s.refresh(off_platform)
hint = await family_svc.milestone_trigger(family["owner"], on_platform)
assert hint and "Android app" in hint
assert await family_svc.milestone_trigger(family["owner"], off_platform) is None
assert await family_svc.milestone_is_open(family["owner"], on_platform.id) is True
+29
View File
@@ -0,0 +1,29 @@
"""Structural tests for the family blueprint (milestone 463 step 3) — every
endpoint is routed, and every write hands the service its caller, where the
note's write gate lives. What the writes do is in
tests/test_integration_family_promotion.py."""
import inspect
def test_family_blueprint_routes_every_endpoint():
from scribe.app import create_app
app = create_app()
assert "family" in app.blueprints
rules = {(r.rule, m) for r in app.url_map.iter_rules() for m in r.methods}
for rule, method in (
("/api/family/ideas", "GET"),
("/api/family/ideas/<int:note_id>", "GET"),
("/api/family/ideas/<int:note_id>/propose", "POST"),
("/api/family/ideas/<int:note_id>/promote", "POST"),
("/api/family/ideas/<int:note_id>/retire", "POST"),
("/api/family/decisions", "GET"),
("/api/family/decisions/<int:decision_id>/undo", "POST"),
):
assert (rule, method) in rules, f"{method} {rule} is not routed"
def test_every_engine_write_takes_the_caller_and_who_decided():
from scribe.services import family as svc
for name in ("propose", "promote", "retire", "undo"):
params = inspect.signature(getattr(svc, name)).parameters
assert "user_id" in params and "decided_via" in params, name