feat(family): the adoption ledger - assessment, the conflict order, owed->task, recheck, the adoption matrix (milestone 463 step 4, #4990)
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 1m0s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Failing after 1m28s
CI & Build / Build & push image (push) Skipped

- services/family_adoption.py: assess one project against one canon idea by
  the four outcomes in order (exempt, variant, adopted, owed). Every outcome
  needs a reason and adopted needs evidence. The engine records the precedents
  itself: this idea's answers elsewhere, and this project's answers to the
  nearest ideas. The same answer given twice records nothing.
- owed files a task in the OWING project, tagged to the System matching the
  idea's canonical area, naming the gap and the reference for that project's
  language. The task follows the answer: adopted closes it, exempt or variant
  cancels it, owed again reopens it. Each move is logged on the task.
- the conflict order is enforced: every ground above the deciding one must
  say why it did not decide. The losing side is folded into the idea's note as
  a trap, an alternative or a condition branch, the version moves, and both
  rows are answered against the revision.
- recheck is derived (row version != idea version). family.revise moves the
  version when substance changes. undo covers a project's latest answer too.
- set_family_references names the reference implementations.
- MCP: get/list/assess adoption, resolve_family_conflict, revise_family_idea,
  set_family_references. Web: GET /api/family/matrix.
- UI: an adoption matrix on /family (platform filter, cell detail with reason,
  recheck and owed-task link) and the same matrix narrowed to one project on
  its Family tab. The decision log now reads project-level decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 11:05:00 -04:00
co-authored by Claude Opus 5.5
parent faa1b72307
commit 201e09901b
12 changed files with 2386 additions and 57 deletions
+81 -3
View File
@@ -40,15 +40,29 @@ export interface IdeaSnapshot {
platforms: string[];
}
export type AdoptionStatus = "unassessed" | "adopted" | "variant" | "exempt" | "owed";
/** One project's answer as a decision records it. */
export interface AdoptionSnapshot {
status: AdoptionStatus;
reason: string;
canon_version: number | null;
}
/**
* A decision about the idea itself (project_id null) snapshots the idea; a
* project's assessment (project_id set) snapshots that project's answer.
*/
export interface FamilyDecision {
id: number;
idea_id: number;
idea_title?: string;
project_id: number | null;
project_title?: string | null;
action: DecisionAction;
reason: string;
before: IdeaSnapshot | null;
after: IdeaSnapshot | null;
before: IdeaSnapshot | AdoptionSnapshot | null;
after: IdeaSnapshot | AdoptionSnapshot | null;
evidence: Record<string, unknown>;
precedent_ids: number[];
decided_via: "agent" | "operator" | "system";
@@ -57,7 +71,17 @@ export interface FamilyDecision {
undoable?: boolean;
}
export async function listFamilyIdeas(status = ""): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[] }> {
/** One ground of the conflict order, in order. */
export interface ConflictGround {
key: string;
title: string;
test: string;
fold_as: "alternative" | "trap" | "condition";
}
export async function listFamilyIdeas(
status = "",
): Promise<{ ideas: FamilyIdea[]; criteria: Criterion[]; conflict_order?: ConflictGround[] }> {
const q = status ? `?status=${encodeURIComponent(status)}` : "";
return apiGet(`/api/family/ideas${q}`);
}
@@ -76,3 +100,57 @@ export async function undoFamilyDecision(id: number, reason: string) {
export async function retireFamilyIdea(noteId: number, reason: string) {
return apiPost<{ decision: FamilyDecision }>(`/api/family/ideas/${noteId}/retire`, { reason });
}
export interface Outcome {
key: AdoptionStatus;
title: string;
test: string;
}
/** One project's answer to one canon idea — a cell of the matrix. */
export interface AdoptionCell {
project_id: number;
project_title: string;
idea_id: number;
idea_title: string;
idea_version: number;
/** The project is on one of the idea's platforms. */
in_scope: boolean;
/** A ledger row exists. False: the idea reaches the project, nobody asked yet. */
reached: boolean;
status: AdoptionStatus;
reason: string;
canon_version: number | null;
assessed_at: string | null;
decided_via: "agent" | "operator" | "system" | null;
/** Answered against a canon version that is not the current one. */
needs_recheck: boolean;
owed_task: { id: number; title: string; status: string } | null;
}
export interface MatrixIdea {
note_id: number;
title: string;
note_type: string;
is_task: boolean;
canon_version: number;
applies_when: string;
platforms: string[];
}
export interface AdoptionMatrix {
ideas: MatrixIdea[];
projects: { id: number; title: string; platforms: string[] }[];
cells: AdoptionCell[];
outcomes: Outcome[];
}
export async function fetchAdoptionMatrix(
opts: { platform?: string; projectId?: number } = {},
): Promise<AdoptionMatrix> {
const q = new URLSearchParams();
if (opts.platform) q.set("platform", opts.platform);
if (opts.projectId) q.set("project_id", String(opts.projectId));
const qs = q.toString();
return apiGet(`/api/family/matrix${qs ? `?${qs}` : ""}`);
}
@@ -0,0 +1,328 @@
<script setup lang="ts">
/**
* The adoption matrix (milestone 463 step 4): every project × every canon
* family idea that reaches it, and the project's answer.
*
* Nobody approves an answer — the agent assesses each project against the
* four outcomes and records why. This is where a person sees that happening:
* which projects adopted, which depart and for what fact, which owe the work
* (with the task filed in that project), and which answers were given
* against an older version of the idea and need a recheck. Disagreeing is an
* undo in the decision log below.
*
* `projectId` narrows it to one project's answers — the same component, so a
* project's Family tab and this page cannot describe a cell differently.
*/
import { computed, onMounted, ref, watch } from "vue";
import {
fetchAdoptionMatrix,
type AdoptionCell, type AdoptionMatrix, type AdoptionStatus,
} from "@/api/family";
import { usePlatformsStore } from "@/stores/platforms";
import { fmtStamp } from "@/utils/dateFormat";
import { recordHref } from "@/utils/recordHref";
const props = defineProps<{ projectId?: number }>();
const platformsStore = usePlatformsStore();
const matrix = ref<AdoptionMatrix | null>(null);
const platform = ref("");
const loading = ref(true);
const failed = ref(false);
const selectedKey = ref<string | null>(null);
const LABELS: Record<AdoptionStatus, string> = {
unassessed: "Not assessed",
adopted: "Adopted",
variant: "Variant",
exempt: "Doesn't apply",
owed: "Owed",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [, data] = await Promise.all([
platformsStore.fetchCatalog(),
fetchAdoptionMatrix({ platform: platform.value, projectId: props.projectId }),
]);
matrix.value = data;
if (selectedKey.value && !data.cells.some((c) => key(c) === selectedKey.value)) {
selectedKey.value = null;
}
} catch {
// A matrix that failed to load must not read as "nobody owes anything".
failed.value = true;
} finally {
loading.value = false;
}
}
function key(c: { project_id: number; idea_id: number }): string {
return `${c.project_id}:${c.idea_id}`;
}
const cellByKey = computed(() => {
const out: Record<string, AdoptionCell> = {};
for (const c of matrix.value?.cells ?? []) out[key(c)] = c;
return out;
});
const selected = computed(() =>
selectedKey.value ? cellByKey.value[selectedKey.value] ?? null : null,
);
const outcomeTest = computed(() => {
const out: Partial<Record<AdoptionStatus, string>> = {};
for (const o of matrix.value?.outcomes ?? []) out[o.key] = o.test;
return out;
});
/** The answers that want attention: owed, or given against an older canon. */
const summary = computed(() => {
const cells = matrix.value?.cells ?? [];
return {
owed: cells.filter((c) => c.status === "owed").length,
recheck: cells.filter((c) => c.needs_recheck).length,
unassessed: cells.filter((c) => c.status === "unassessed").length,
};
});
function toggle(c: AdoptionCell) {
selectedKey.value = selectedKey.value === key(c) ? null : key(c);
}
function cellTitle(c: AdoptionCell): string {
const parts = [LABELS[c.status]];
if (c.needs_recheck) parts.push(`answered against v${c.canon_version}, now v${c.idea_version}`);
if (c.reason) parts.push(c.reason);
return parts.join(" — ");
}
onMounted(load);
watch(platform, load);
defineExpose({ load });
</script>
<template>
<div class="fam-matrix-block">
<div class="fam-matrix-head">
<p class="fam-muted">
<template v-if="projectId">This project's answer to each family idea that reaches it.</template>
<template v-else>Each project's answer to each canon idea that reaches it.</template>
The agent answers by the four outcomes below and records why; “recheck” means
the idea changed after it was answered.
</p>
<label v-if="platformsStore.catalog.length" class="fam-select-label">
Platform
<select v-model="platform" class="fam-select">
<option value="">All</option>
<option v-for="p in platformsStore.catalog" :key="p.slug" :value="p.slug">{{ p.name }}</option>
</select>
</label>
</div>
<p v-if="loading && !matrix" class="fam-muted">Loading…</p>
<div v-else-if="failed" class="fam-note">
<strong>The adoption matrix couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty ledger.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<template v-else-if="matrix">
<p v-if="!matrix.ideas.length" class="fam-muted">
<template v-if="platform">No canon idea is for this platform yet.</template>
<template v-else>No idea is canon yet, so nothing has reached a project.</template>
</p>
<p v-else-if="!matrix.projects.length" class="fam-muted">
<template v-if="projectId">
No canon idea reaches this project — it is on none of their platforms.
</template>
<template v-else>
No project is on a platform any canon idea is for. A project joins a platform
on its Family tab, or by detection from a bound repo.
</template>
</p>
<template v-else>
<p class="fam-summary">
{{ summary.owed }} owed · {{ summary.recheck }} to recheck ·
{{ summary.unassessed }} not assessed
</p>
<div class="fam-matrix-wrap" role="region" aria-label="Adoption matrix" tabindex="0">
<table class="fam-matrix">
<thead>
<tr>
<th scope="col" class="fam-corner">Project</th>
<th v-for="idea in matrix.ideas" :key="idea.note_id" scope="col">
<router-link
:to="recordHref({ id: idea.note_id, is_task: idea.is_task, note_type: idea.note_type })"
:title="idea.applies_when ? `Applies when: ${idea.applies_when}` : undefined"
>{{ idea.title }}</router-link>
<span class="fam-version">v{{ idea.canon_version }}</span>
</th>
</tr>
</thead>
<tbody>
<tr v-for="p in matrix.projects" :key="p.id">
<th scope="row">
<router-link :to="`/projects/${p.id}`">{{ p.title }}</router-link>
</th>
<td v-for="idea in matrix.ideas" :key="idea.note_id">
<button
v-if="cellByKey[`${p.id}:${idea.note_id}`]"
type="button"
:class="['fam-cell', `fam-cell-${cellByKey[`${p.id}:${idea.note_id}`].status}`,
{ 'fam-cell-on': selectedKey === `${p.id}:${idea.note_id}` }]"
:aria-pressed="selectedKey === `${p.id}:${idea.note_id}`"
:title="cellTitle(cellByKey[`${p.id}:${idea.note_id}`])"
@click="toggle(cellByKey[`${p.id}:${idea.note_id}`])"
>
{{ LABELS[cellByKey[`${p.id}:${idea.note_id}`].status] }}
<span v-if="cellByKey[`${p.id}:${idea.note_id}`].needs_recheck" class="fam-recheck">recheck</span>
</button>
<span v-else class="fam-cell-none" title="Not on this idea's platforms">—</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="selected" class="fam-detail" aria-live="polite">
<div class="fam-detail-head">
<strong>{{ selected.project_title }} · {{ selected.idea_title }}</strong>
<span :class="['fam-cell', `fam-cell-${selected.status}`]">{{ LABELS[selected.status] }}</span>
<button type="button" class="btn-ghost btn-sm" @click="selectedKey = null">Close</button>
</div>
<p v-if="selected.reason" class="fam-detail-reason">{{ selected.reason }}</p>
<p v-else-if="!selected.reached" class="fam-muted">
The idea reaches this project, but nobody has been asked yet — whoever promoted
it could not write here. The project's own agent answers it on its next pass.
</p>
<p v-else-if="selected.status === 'unassessed'" class="fam-muted">
Waiting for the agent to assess it against the four outcomes.
</p>
<p v-if="selected.needs_recheck" class="fam-detail-recheck">
Answered against v{{ selected.canon_version }}; the idea is now
v{{ selected.idea_version }}, so this answer needs a recheck.
</p>
<p v-if="!selected.in_scope" class="fam-muted">
This project is no longer on the idea's platforms; the answer is kept as history.
</p>
<p v-if="selected.owed_task" class="fam-detail-task">
Owed task:
<router-link :to="recordHref({ id: selected.owed_task.id, is_task: true })">
{{ selected.owed_task.title }}
</router-link>
<span class="fam-muted">({{ selected.owed_task.status.replace("_", " ") }})</span>
</p>
<p v-if="selected.assessed_at" class="fam-muted">
Answered {{ fmtStamp(selected.assessed_at) }}
<template v-if="selected.decided_via">by the {{ selected.decided_via }}</template>
<template v-if="selected.canon_version"> against v{{ selected.canon_version }}</template>.
</p>
</div>
</template>
<details class="fam-outcomes">
<summary>The four outcomes</summary>
<dl>
<template v-for="o in matrix.outcomes" :key="o.key">
<dt>{{ LABELS[o.key] }}</dt>
<dd>{{ outcomeTest[o.key] }}</dd>
</template>
</dl>
</details>
</template>
</div>
</template>
<style scoped>
.fam-matrix-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.5rem 1rem; margin-bottom: 0.75rem; }
.fam-matrix-head .fam-muted { flex: 1; min-width: 16rem; 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-select-label { display: flex; align-items: center; gap: 0.4rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-select {
padding: 0.3rem 0.5rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.85rem;
}
.fam-summary { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-matrix-wrap {
overflow-x: auto;
max-width: 100%;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
}
.fam-matrix-wrap:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
button.fam-cell:focus-visible { outline: none; box-shadow: var(--fs-focus-ring); }
.fam-matrix { border-collapse: collapse; font-size: 0.85rem; min-width: 100%; }
.fam-matrix th, .fam-matrix td {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--fs-border-color);
text-align: left;
vertical-align: middle;
}
.fam-matrix thead th { font-weight: 500; min-width: 9rem; max-width: 14rem; }
.fam-matrix tbody th { font-weight: 500; white-space: nowrap; }
.fam-matrix tbody tr:last-child th, .fam-matrix tbody tr:last-child td { border-bottom: none; }
.fam-corner { color: var(--fs-text-tertiary); }
.fam-version { display: block; color: var(--fs-text-tertiary); font-size: 0.75rem; font-weight: 400; }
.fam-cell {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font: inherit;
font-size: 0.78rem;
padding: 0.1rem 0.5rem;
border-radius: var(--fs-radius-pill);
border: 1px solid var(--fs-border-color);
background: transparent;
color: var(--fs-text-secondary);
white-space: nowrap;
}
button.fam-cell { cursor: pointer; }
button.fam-cell:hover { background: var(--fs-surface-hover); }
.fam-cell-on { outline: 2px solid var(--fs-text-secondary); outline-offset: 1px; }
.fam-cell-adopted { background: var(--fs-status-done-bg); color: var(--fs-status-done-fg); }
.fam-cell-owed { background: var(--fs-status-todo-bg); color: var(--fs-status-todo-fg); }
.fam-cell-variant { background: var(--fs-status-in-progress-bg); color: var(--fs-status-in-progress-fg); }
.fam-cell-exempt { color: var(--fs-status-cancelled-fg); }
.fam-cell-unassessed { color: var(--fs-text-tertiary); border-style: dashed; }
.fam-recheck {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--fs-text-primary);
}
.fam-cell-none { color: var(--fs-text-tertiary); }
.fam-detail {
margin-top: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
background: var(--fs-surface-raised);
}
.fam-detail-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; }
.fam-detail-head .btn-ghost { margin-left: auto; }
.fam-detail p { margin: 0.4rem 0 0; font-size: 0.875rem; }
.fam-detail-reason { color: var(--fs-text-primary); }
.fam-detail-recheck { color: var(--fs-text-secondary); }
.fam-outcomes { margin-top: 0.75rem; font-size: 0.85rem; color: var(--fs-text-secondary); }
.fam-outcomes summary { cursor: pointer; color: var(--fs-text-secondary); }
.fam-outcomes dl { margin: 0.5rem 0 0; }
.fam-outcomes dt { font-weight: 500; color: var(--fs-text-primary); }
.fam-outcomes dd { margin: 0 0 0.4rem 1rem; }
</style>
@@ -16,6 +16,10 @@
*
* Each change is saved as it is made: one platform, one request, applied
* whole or refused whole.
*
* Below the platforms: this project's answer to each canon idea that reaches
* it — the adoption matrix narrowed to one row, so it reads exactly as the
* family page does.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
@@ -24,6 +28,7 @@ import {
type PlatformState, type ProjectPlatform, type SettablePlatformState,
} from "@/api/platforms";
import { usePlatformsStore } from "@/stores/platforms";
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
const props = defineProps<{ projectId: number; canWrite: boolean }>();
@@ -150,6 +155,11 @@ watch(() => props.projectId, load);
</li>
</ul>
</template>
<section class="pft-answers" aria-labelledby="pft-answers-title">
<h3 id="pft-answers-title" class="pft-title">Family ideas</h3>
<FamilyAdoptionMatrix :key="projectId" :project-id="projectId" />
</section>
</div>
</template>
@@ -195,6 +205,7 @@ watch(() => props.projectId, load);
font-size: 0.85rem;
flex-shrink: 0;
}
.pft-answers { margin-top: 1.75rem; }
@media (max-width: 640px) {
.pft-row { flex-direction: column; align-items: stretch; }
}
+54 -8
View File
@@ -4,17 +4,21 @@
* 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.
* shown at the top, and records why — and nobody approves a project's answer
* either: the adoption matrix shows each project's answer to each canon idea
* as the agent gave it. 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 (or on one project's answer). Both ask for a reason,
* because the reason is what the next similar decision 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,
type AdoptionSnapshot, type ConflictGround, type Criterion, type FamilyDecision,
type FamilyIdea, type IdeaSnapshot, type IdeaStatus,
} from "@/api/family";
import FamilyAdoptionMatrix from "@/components/FamilyAdoptionMatrix.vue";
import { useToastStore } from "@/stores/toast";
import { fmtStamp } from "@/utils/dateFormat";
import { recordHref } from "@/utils/recordHref";
@@ -23,6 +27,8 @@ const PAGE = 50;
const toast = useToastStore();
const criteria = ref<Criterion[]>([]);
const conflictOrder = ref<ConflictGround[]>([]);
const matrixRef = ref<InstanceType<typeof FamilyAdoptionMatrix> | null>(null);
const ideas = ref<FamilyIdea[]>([]);
const decisions = ref<FamilyDecision[]>([]);
const statusFilter = ref<"" | IdeaStatus>("");
@@ -55,6 +61,7 @@ async function load() {
]);
ideas.value = ideaData.ideas;
criteria.value = ideaData.criteria;
conflictOrder.value = ideaData.conflict_order ?? [];
decisions.value = log;
moreDecisions.value = log.length === PAGE;
} catch {
@@ -103,7 +110,7 @@ async function confirmPending() {
else await retireFamilyIdea(p.id, reason);
toast.show(p.kind === "undo" ? "Decision undone" : "Idea retired");
pending.value = null;
await load();
await Promise.all([load(), matrixRef.value?.load()]);
} catch (e: unknown) {
toast.show(apiErrorMessage(e, p.kind === "undo" ? "Could not undo" : "Could not retire"), "error");
} finally {
@@ -144,14 +151,44 @@ function evidenceLines(d: FamilyDecision): { label: string; lines: string[] }[]
if (typeof ev.trigger === "string") {
out.push({ label: "Trigger", lines: [ev.trigger] });
}
const conflict = ev.conflict as {
ground: string; grounds_checked: Record<string, string>; canon: string; other: string;
folded_as: string; conditions: { canon: string; other: string } | null;
} | undefined;
if (conflict) {
const title = (k: string) => conflictOrder.value.find((g) => g.key === k)?.title ?? k;
const lines = [
`Decided by: ${title(conflict.ground)}`,
...Object.entries(conflict.grounds_checked || {})
.map(([k, why]) => `${title(k)} did not decide — ${why}`),
`Canon: ${conflict.canon}; the other side (${conflict.other}) was folded into the note as ${
conflict.folded_as === "condition" ? "the branch for its condition" : `a ${conflict.folded_as}`}`,
];
if (conflict.conditions) {
lines.push(`When ${conflict.conditions.canon}: ${conflict.canon}`,
`When ${conflict.conditions.other}: ${conflict.other}`);
}
out.push({ label: "Conflict", lines });
}
return out;
}
const ADOPTION_LABELS: Record<string, string> = {
unassessed: "not assessed", adopted: "adopted", variant: "variant",
exempt: "doesn't apply", owed: "owed",
};
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}`;
if (d.project_id !== null) {
const row = a as AdoptionSnapshot;
const version = row.canon_version ? ` against v${row.canon_version}` : "";
return `${ADOPTION_LABELS[row.status] ?? row.status}${version}`;
}
const idea = a as IdeaSnapshot;
const where = idea.platforms.length ? ` · ${idea.platforms.join(", ")}` : "";
return `${idea.status} v${idea.canon_version}${where}`;
}
onMounted(load);
@@ -246,6 +283,11 @@ onMounted(load);
</ul>
</section>
<section class="fam-section" aria-labelledby="fam-matrix">
<h2 id="fam-matrix">Adoption</h2>
<FamilyAdoptionMatrix ref="matrixRef" />
</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>
@@ -255,6 +297,10 @@ onMounted(load);
<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>
<template v-if="d.project_id !== null">
<span class="fam-muted">for</span>
<router-link :to="`/projects/${d.project_id}`">{{ d.project_title ?? `project ${d.project_id}` }}</router-link>
</template>
<span class="fam-muted">
#{{ d.id }} · {{ d.decided_via }} · {{ d.created_at ? fmtStamp(d.created_at) : "" }}
</span>
+7 -2
View File
@@ -163,9 +163,11 @@ _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.
# Family canon (milestone 463): ideas, one idea with its precedents, the
# decision log, and the adoption ledger. Reads of records the caller can
# read.
"list_family_ideas", "get_family_idea", "list_family_decisions",
"get_family_adoption", "list_family_adoptions",
# 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.
@@ -193,6 +195,9 @@ _WRITE_TOOLS = frozenset({
# family canon — the promotion engine
"propose_family_idea", "promote_family_idea", "retire_family_idea",
"undo_family_decision",
# family canon — the adoption ledger
"revise_family_idea", "assess_family_adoption", "resolve_family_conflict",
"set_family_references",
"bind_repo", "unbind_repo",
# snippets, processes, the shape ledger
"create_snippet", "update_snippet", "delete_snippet", "verify_snippet",
+205 -11
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
from scribe.mcp._context import current_user_id
from scribe.services import family as family_svc
from scribe.services import family_adoption as adoption_svc
async def list_family_ideas(
@@ -32,8 +33,9 @@ async def list_family_ideas(
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.
full decision history, ledger counts, THE THREE CRITERIA, the `ledger`
(every project's answer, with `needs_recheck`), 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.
@@ -45,6 +47,7 @@ async def get_family_idea(note_id: int) -> dict:
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)
idea["ledger"] = await adoption_svc.list_adoptions(uid, idea_id=note_id)
return idea
@@ -146,10 +149,12 @@ async def retire_family_idea(note_id: int, reason: str) -> dict:
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.
"""Reverse a family decision — a promotion, a revision, a retirement, a
proposal, or one project's assessment — restoring the state it recorded
as `before`. Only the latest decision on an idea (or on one project's
answer to it) can be undone; the undo is logged too, so the history
keeps both. Undoing an owed answer closes its task; undoing back to owed
reopens it.
Args:
decision_id: the decision (list_family_decisions).
@@ -158,26 +163,215 @@ async def undo_family_decision(decision_id: int, reason: str) -> dict:
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:
async def list_family_decisions(
note_id: int = 0, project_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.
veto, revision, retirement, assessment and undo, with its reason,
evidence and the precedents it followed. `undoable` marks the decision an
undo would reverse — per idea, and per project's answer.
Args:
note_id: only this idea's decisions. 0 = all.
project_id: only this project's assessments. 0 = all.
limit / offset: page through the log.
"""
rows = await family_svc.list_decisions(
current_user_id(), idea_id=note_id or None,
current_user_id(), idea_id=note_id or None, project_id=project_id or None,
limit=max(1, min(limit, 200)), offset=max(0, offset),
)
return {"decisions": rows, "limit": limit, "offset": offset}
async def revise_family_idea(
note_id: int, reason: str, applies_when: str = "", platforms: list[str] | None = None,
evidence: list[str] | None = None,
) -> dict:
"""Record that a canon idea's SUBSTANCE changed — you rewrote its note's
approach, its traps or its checklist, or its applicability or platforms
moved. The canon version moves, so every project's answer given against
the old version reads `needs_recheck` until it is assessed again.
A typo fix is not a revision. A change a project that adopted the old
version would need to act on is.
Args:
note_id: the canon idea.
reason: what changed, in a sentence.
applies_when: a new 'when it applies'. Empty = keep the current one.
platforms: new platform slugs. Omit = keep the current ones.
evidence: what prompted the change, if anything.
"""
return await family_svc.revise(
current_user_id(), note_id, reason=reason,
applies_when=applies_when or None, platforms=platforms, evidence=evidence,
)
async def get_family_adoption(project_id: int, idea_id: int) -> dict:
"""Everything one assessment needs: the idea (its 'when it applies',
platforms and version), this project's current answer, THE FOUR
OUTCOMES, the `precedents` — this idea's answers in other projects and
this project's answers to the nearest ideas — and the reference
implementations beside this project's languages. Read it before
assess_family_adoption.
Args:
project_id: the project answering.
idea_id: the canon idea.
"""
return await adoption_svc.get_adoption(current_user_id(), project_id, idea_id)
async def list_family_adoptions(
project_id: int = 0, idea_id: int = 0, status: str = "", needs_recheck: bool = False,
platform: str = "",
) -> dict:
"""The adoption ledger: each project's answer to each canon idea that
reaches it — `unassessed`, `adopted`, `variant`, `exempt` or `owed`, with
its reason, the canon version it was given against, `needs_recheck` when
the idea has moved on since, and the owed task.
Args:
project_id: one project's answers. 0 = all you can read.
idea_id: one idea's answers. 0 = all canon.
status: one outcome. Empty = all.
needs_recheck: only answers given against an older canon version.
platform: only ideas for this platform slug.
"""
rows = await adoption_svc.list_adoptions(
current_user_id(), project_id=project_id or None, idea_id=idea_id or None,
status=status or None, recheck_only=needs_recheck, platform=platform or None,
)
return {"adoptions": rows}
async def assess_family_adoption(
project_id: int,
idea_id: int,
outcome: str,
reason: str,
evidence: list[str] | None = None,
precedent_ids: list[int] | None = None,
system_ids: list[int] | None = None,
) -> dict:
"""Answer one canon family idea for one project. YOU decide; nobody
approves. Judge in this order and stop at the first that holds:
1. exempt — the idea's 'when it applies' is false for this project.
`reason` names the fact about the project that makes it false.
2. variant — it applies, and the project departs for a reason that names
a FACT about itself the canon did not account for. A preference, a
taste, or "we already did it another way" is not a reason: that is
owed — or, if this project's way is better rather than different, a
conflict (resolve_family_conflict).
3. adopted — it applies and the project does it. `evidence` names where
(a file, a commit, a task, a CI run).
4. owed — none of the above. A task is filed in THIS project naming the
gap and the reference implementation for its language. Nothing edits
another repository; the project picks the task up itself.
Read get_family_adoption first: answer consistently with its precedents
unless this project differs in a way you can name in `reason`. The
engine also records the precedents it found itself.
The owed task follows the answer: adopted closes it as done, exempt or
variant cancels it, owed again reopens it. The same answer given twice
records nothing the second time.
Args:
project_id: the project answering.
idea_id: the canon idea.
outcome: exempt | variant | adopted | owed.
reason: why — required for every outcome.
evidence: where it is done (required for adopted), or what you checked.
precedent_ids: earlier family decisions you followed, if any.
system_ids: Systems for an owed task. Omit to match the idea's own.
"""
return await adoption_svc.assess(
current_user_id(), project_id, idea_id, outcome=outcome, reason=reason,
evidence=evidence, precedent_ids=precedent_ids, system_ids=system_ids,
)
async def resolve_family_conflict(
idea_id: int,
canon_project_id: int,
other_project_id: int,
ground: str,
fold: str,
reason: str,
grounds_checked: dict | None = None,
evidence: list[str] | None = None,
conditions: dict | None = None,
precedent_ids: list[int] | None = None,
) -> dict:
"""Settle two projects that solve the same canon idea differently, each
for reasons it believes. YOU decide, by THE CONFLICT ORDER — the first
ground that applies wins, and for every ground above it you say in
`grounds_checked` why it did not decide:
1. operator_stance — one side follows a stance the operator stated (a
rule, a preference, a recorded decision). Name it in `evidence`.
2. covers_failure — one side covers a recorded failure (an incident, an
issue, a lesson) the other does not. Name it in `evidence`. The other
side becomes owed.
3. split_by_condition — both are right under different conditions. The
canon splits: `conditions={"canon": …, "other": …}`, and each side is
canon where its condition holds.
4. most_recent_complete — none of the above: the side verified most
recently and covering the most wins. Name the verification.
`canon_project_id` is the side whose approach the idea's note states
after this. If the note says something else now, rewrite it first
(update_note) — the note is the canon.
The losing side's reasoning, `fold`, is appended to the idea's note as a
trap (ground 2), an alternative (1, 4) or the branch for its condition
(3). It is never dropped. The version moves, so every other project's
answer reads `needs_recheck`. The canon side is answered adopted; the
other owed (with a task in its project) or, in a split, adopted.
Args:
idea_id: the canon idea.
canon_project_id: the side whose approach is canon after this.
other_project_id: the side that loses, or the split's other branch.
ground: operator_stance | covers_failure | split_by_condition | most_recent_complete.
fold: the other side's reasoning, as it should read in the note.
reason: the decision in one or two sentences.
grounds_checked: {earlier ground: why it did not decide}.
evidence: the stance, the failure or the verification.
conditions: for a split, {"canon": condition, "other": condition}.
precedent_ids: earlier family decisions you followed, if any.
"""
return await adoption_svc.resolve_conflict(
current_user_id(), idea_id, canon_project_id=canon_project_id,
other_project_id=other_project_id, ground=ground, grounds_checked=grounds_checked,
fold=fold, evidence=evidence, reason=reason, conditions=conditions,
precedent_ids=precedent_ids,
)
async def set_family_references(note_id: int, snippet_ids: list[int]) -> dict:
"""Set a family idea's reference implementations — the snippets an owed
task points a project at, ideally one per language. Replaces the list.
The idea is what transfers; a reference is where to start, not code to
copy.
Args:
note_id: the family idea.
snippet_ids: snippets implementing it. [] clears the list.
"""
refs = await adoption_svc.set_references(current_user_id(), note_id, snippet_ids or [])
return {"references": refs}
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,
list_family_decisions, revise_family_idea, get_family_adoption,
list_family_adoptions, assess_family_adoption, resolve_family_conflict,
set_family_references,
):
mcp.tool(name=fn.__name__)(fn)
+25 -4
View File
@@ -1,7 +1,8 @@
"""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
The agent decides promotions and each project's answers through the MCP
tools; this door exists so a person can READ every decision and its reasons
— the ideas, the adoption matrix, the log — 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).
@@ -13,6 +14,7 @@ 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
from scribe.services import family_adoption as adoption_svc
logger = logging.getLogger(__name__)
@@ -38,7 +40,10 @@ async def list_ideas_route():
platform=request.args.get("platform") or None,
limit=limit, offset=offset,
)
return jsonify({"ideas": ideas, "criteria": list(family_svc.CRITERIA)})
return jsonify({
"ideas": ideas, "criteria": list(family_svc.CRITERIA),
"conflict_order": list(adoption_svc.CONFLICT_ORDER),
})
@family_bp.route("/ideas/<int:note_id>", methods=["GET"])
@@ -100,13 +105,29 @@ async def retire_route(note_id: int):
return jsonify(result)
@family_bp.route("/matrix", methods=["GET"])
@login_required
async def matrix_route():
"""Projects × canon ideas — the adoption matrix. Read-only: the agent
answers through assess_family_adoption; a person reads the answers here
and undoes one from the decision log if they disagree."""
matrix = await adoption_svc.adoption_matrix(
get_current_user_id(),
platform=request.args.get("platform") or None,
project_id=request.args.get("project_id", type=int) or None,
)
return jsonify(matrix)
@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)
project_id = request.args.get("project_id", type=int)
rows = await family_svc.list_decisions(
get_current_user_id(), idea_id=idea_id or None, limit=limit, offset=offset,
get_current_user_id(), idea_id=idea_id or None, project_id=project_id or None,
limit=limit, offset=offset,
)
return jsonify({"decisions": rows, "limit": limit, "offset": offset})
+162 -27
View File
@@ -232,35 +232,52 @@ async def list_ideas(
async def list_decisions(
user_id: int, *, idea_id: int | None = None, limit: int = 50, offset: int = 0,
user_id: int, *, idea_id: int | None = None, project_id: int | None = None,
limit: int = 50, offset: int = 0,
) -> list[dict]:
"""The decision log, newest first, over ideas the caller can read. Each
row says whether it is the one an undo would reverse."""
row says whether it is the one an undo would reverse; a project's
assessment also carries the project's title."""
async with async_session() as session:
query = (
select(FamilyDecision, Note.title)
select(FamilyDecision, Note.title, Project.title)
.join(Note, Note.id == FamilyDecision.idea_id)
.outerjoin(Project, Project.id == FamilyDecision.project_id)
.where(access.readable_notes_clause(user_id))
)
if idea_id:
query = query.where(FamilyDecision.idea_id == idea_id)
if project_id:
query = query.where(FamilyDecision.project_id == project_id)
rows = (await session.execute(
query.order_by(FamilyDecision.id.desc()).limit(limit).offset(offset)
)).all()
latest = await _latest_idea_decisions(session, {d.idea_id for d, _ in rows})
latest = await _latest_idea_decisions(
session, {d.idea_id for d, _, _ in rows if d.project_id is None})
latest_pair = await _latest_pair_decisions(
session, {(d.idea_id, d.project_id) for d, _, _ in rows if d.project_id is not None})
out = []
for d, title in rows:
for d, title, project_title in rows:
item = _decision_dict(d, title)
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
if d.project_id is None:
item["undoable"] = latest.get(d.idea_id) == d.id and _can_undo(d)
else:
item["project_title"] = project_title
item["undoable"] = latest_pair.get((d.idea_id, d.project_id)) == d.id and _can_undo(d)
out.append(item)
return out
def _can_undo(d: FamilyDecision) -> bool:
"""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
"""A decision that changed something: an idea-level one, or one
project's assessment. A veto (a `propose` whose before and after agree)
changed nothing, and an undo is undone by deciding again, not by undoing
the undo."""
if d.before == d.after:
return False
if d.project_id is None:
return d.action in _IDEA_ACTIONS
return d.action == "assess"
def _undoable_id(decisions_newest_first) -> int | None:
@@ -270,6 +287,22 @@ def _undoable_id(decisions_newest_first) -> int | None:
return None
async def _latest_pair_decisions(session, pairs: set[tuple[int, int]]) -> dict[tuple[int, int], int]:
"""The latest decision about each (idea, project) answer — the only one of
a project's decisions on an idea that an undo may reverse."""
if not pairs:
return {}
rows = await session.execute(
select(FamilyDecision.idea_id, FamilyDecision.project_id, func.max(FamilyDecision.id))
.where(
FamilyDecision.idea_id.in_({i for i, _ in pairs}),
FamilyDecision.project_id.in_({p for _, p in pairs}),
)
.group_by(FamilyDecision.idea_id, FamilyDecision.project_id)
)
return {(i, p): d for i, p, d in rows.all() if (i, p) in pairs}
async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
if not idea_ids:
return {}
@@ -281,11 +314,10 @@ async def _latest_idea_decisions(session, idea_ids: set[int]) -> dict[int, int]:
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."""
async def nearest_ideas(user_id: int, note_id: int, limit: int = 5) -> list[tuple[float, Note]]:
"""The family ideas nearest this record by meaning, best first, as
(score, note). Empty when there are no other ideas or the embedder is
unavailable — precedent is a help to a decision, never a gate on it."""
from scribe.services.embeddings import embedding_text, semantic_search_notes
async with async_session() as session:
@@ -305,7 +337,15 @@ async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
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]
return [(score, n) for score, n in hits if n.id in idea_ids][:limit]
async def precedents(user_id: int, note_id: int, limit: int = 5) -> list[dict]:
"""The decisions on the ideas nearest this one by meaning — what a new
decision about it should be consistent with. Each idea contributes its
latest idea-level decision. Empty when nothing similar has been decided,
or when the embedder is unavailable."""
ranked = await nearest_ideas(user_id, note_id, limit)
if not ranked:
return []
async with async_session() as session:
@@ -521,15 +561,8 @@ async def promote(
# 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
if await _promoted_before(session, note_id):
idea.canon_version = await next_version(session, idea)
idea.status = "canon"
idea.applies_when = applies_when.strip()
idea.updated_at = _now()
@@ -550,6 +583,100 @@ async def promote(
}
async def _promoted_before(session, note_id: int) -> bool:
return bool(await session.scalar(
select(func.count()).select_from(FamilyDecision)
.where(FamilyDecision.idea_id == note_id, FamilyDecision.action == "promote")
))
async def next_version(session, idea: FamilyIdea) -> int:
"""One past every canon version this idea has ever held — including one
an undo rolled back from — so no answer given against an earlier canon
can read as agreeing with the new one. Only idea-level snapshots carry an
idea version; an assessment's `after` holds the version it was judged
against, which is never ahead of the idea's."""
rows = (await session.execute(
select(FamilyDecision.after).where(
FamilyDecision.idea_id == idea.note_id, FamilyDecision.project_id.is_(None),
)
)).scalars().all()
seen = [idea.canon_version or 1] + [int(a.get("canon_version") or 1) for a in rows if a]
return max(seen) + 1
async def _close_unreached(session, note_id: int) -> int:
"""Delete the `unassessed` rows of projects no longer on any of the
idea's platforms — nobody judged them, and the idea no longer reaches
them. Judged rows stay, as history."""
members = (
select(ProjectPlatform.project_id)
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
.where(FamilyIdeaPlatform.note_id == note_id, ProjectPlatform.state.in_(MEMBER_STATES))
)
result = await session.execute(
delete(FamilyAdoption).where(
FamilyAdoption.idea_id == note_id, FamilyAdoption.status == "unassessed",
FamilyAdoption.project_id.not_in(members),
)
)
return result.rowcount or 0
async def revise(
user_id: int, note_id: int, *, reason: str, applies_when: str | None = None,
platforms: list[str] | None = None, evidence: list[str] | None = None,
decided_via: str = "agent",
) -> dict:
"""Record that a canon idea's SUBSTANCE changed — its note was rewritten,
its applicability narrowed or widened, or its platforms changed.
The version moves, so every project's answer given against the earlier
version reads as needing a recheck (derived, never a stored flag). A
project newly in scope gets an `unassessed` row; an `unassessed` row of a
project no longer in scope is closed. Undoable like any idea-level
decision.
`applies_when` and `platforms` are left alone when None; given, they
replace the old ones and may not be empty (canon is always scoped).
"""
if not (reason or "").strip():
raise ValueError("a revision needs a reason — what changed in the idea?")
if not await access.can_write_note(user_id, note_id):
raise ValueError(f"note {note_id} not found or no write access")
if applies_when is not None and not applies_when.strip():
raise ValueError("canon needs an 'applies when' — leave it out to keep the current one")
if platforms is not None and not [p for p in platforms if (p or "").strip()]:
raise ValueError("canon needs at least one platform — leave it out to keep the current ones")
evidence = [str(e).strip() for e in evidence or [] if str(e).strip()]
async with async_session() as session:
idea = await session.get(FamilyIdea, note_id)
if idea is None or idea.status != "canon":
raise ValueError(f"#{note_id} is not family canon — only canon is revised")
known = await _resolve_platforms(session, platforms) if platforms is not None else None
before = await _snapshot(session, idea)
idea.canon_version = await next_version(session, idea)
if applies_when is not None:
idea.applies_when = applies_when.strip()
if known is not None:
await _set_platforms(session, note_id, known.values())
idea.updated_at = _now()
await session.flush()
opened = await _open_ledger(session, user_id, note_id)
closed = await _close_unreached(session, note_id)
decision = _log(
session, idea_id=note_id, action="revise", reason=reason, before=before,
after=await _snapshot(session, idea),
evidence={"evidence": evidence, "ledger_rows_opened": opened,
"ledger_rows_closed": closed},
precedent_ids=None, decided_via=decided_via, user_id=user_id,
)
await session.commit()
await session.refresh(decision)
return {"idea": idea.to_dict(), "decision": decision.to_dict(),
"ledger_rows_opened": opened, "ledger_rows_closed": closed}
async def _existing_decision_ids(ids: list[int]) -> list[int]:
ids = [int(i) for i in ids if i]
if not ids:
@@ -592,8 +719,11 @@ async def retire(user_id: int, note_id: int, *, reason: str, decided_via: str =
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 —
"""Reverse a decision, restoring the state it recorded as `before`. A
project's assessment is handed to the adoption ledger
(family_adoption.undo_assessment); the rest of this is idea-level.
Only the LATEST idea-level decision on an idea can be undone —
undoing an older one would rewrite a state later decisions were built on.
The undo is itself a decision, naming the one it reverses as its
precedent, so the history keeps both.
@@ -607,6 +737,11 @@ async def undo(user_id: int, decision_id: int, *, reason: str, decided_via: str
target = await session.get(FamilyDecision, decision_id)
if target is None:
raise ValueError(f"no such family decision: {decision_id}")
if target.project_id is not None:
# One project's answer: the adoption ledger restores the row.
from scribe.services import family_adoption
return await family_adoption.undo_assessment(
user_id, target, reason=reason, decided_via=decided_via)
if not await access.can_write_note(user_id, target.idea_id):
raise ValueError(f"decision {decision_id} not found or no write access")
async with async_session() as session:
+928
View File
@@ -0,0 +1,928 @@
"""Family canon's adoption ledger (milestone 463 step 4).
Promotion (services/family.py) decides that an idea is canon for some
platforms. This module decides what each project on those platforms does
about it, and keeps the answers honest as the canon moves.
ASSESSMENT — one project, one idea, one of four outcomes, judged in order:
exempt → adopted/variant/owed are never reached: the idea does not apply.
variant → it applies, and the project departs for a FACT about itself.
adopted → it applies and the project does it; the evidence says where.
owed → none of the above. A task is filed in the project to do it.
No person approves an assessment. Consistency comes from precedent: every
assessment records the answers it was checked against — the same idea's
answers in other projects, and this project's answers to the nearest ideas —
found by the engine itself, so "which precedents were consulted" is a fact
about the call. Asking the same question twice with the same answer records
nothing the second time.
OWED → TASK. An `owed` answer files a task in the OWING project — tagged to
the System matching the idea's, naming the gap and the reference
implementation for that project's language. Nothing here edits a repository:
the work is filed where its own project will pick it up. When the answer
moves on, the task follows: `adopted` closes it as done, `exempt` or
`variant` cancels it, `owed` again reopens it. Each change is logged on the
task with the reason.
RECHECK. An answer records the canon version it was given against. When the
idea's version moves (re-promotion, revision, conflict resolution), every
answer given against another version reads `needs_recheck` — DERIVED by
comparing the two numbers, never a stored flag that could go stale.
CONFLICT. When two projects solve the same idea differently and each thinks
its way is the right one, the conflict order decides — the first ground that
applies wins, and every ground above it must be said not to:
1. a stance the operator stated;
2. the approach that covers a recorded failure (the other becomes owed);
3. both right under different conditions (the canon splits by condition);
4. the most recently verified and most complete.
The losing side's reasoning is folded into the idea's note — as a trap, an
alternative, or the branch for its condition — and is never dropped. The
substance changed, so the version moves.
"""
from __future__ import annotations
import logging
from sqlalchemy import func, select
from scribe.models import async_session
from scribe.models.family import (
FamilyAdoption, FamilyDecision, FamilyIdea, FamilyIdeaPlatform, FamilyIdeaReference,
Platform, ProjectPlatform,
)
from scribe.models.note import Note, TaskStatus
from scribe.models.project import Project
from scribe.models.system import RecordSystem, System
from scribe.services import access
from scribe.services import family as family_svc
logger = logging.getLogger(__name__)
# --- the assessment and the conflict order, as product text --------------------
#
# Judged on every install (rules 115, 119); the tool docstrings quote them.
OUTCOMES = (
{
"key": "exempt",
"title": "Does not apply",
"test": (
"The idea's 'when it applies' is false for this project. The reason "
"names the fact about this project that makes it false."
),
},
{
"key": "variant",
"title": "Applies, and the project departs",
"test": (
"It applies, and the project departs for a reason that names a FACT "
"about itself the canon did not account for. A preference, a taste, "
"or 'we already did it another way' is not a reason: that is owed — "
"or, if this project's way is better, a conflict to resolve."
),
},
{
"key": "adopted",
"title": "Applies, and the project does it",
"test": (
"It applies and the project does it. The evidence names where — a "
"file, a commit, a task, a CI run."
),
},
{
"key": "owed",
"title": "Applies, and is not done yet",
"test": (
"It applies, nothing above excuses it, and the project does not do "
"it yet. A task is filed in this project to do it."
),
},
)
OUTCOME_KEYS = tuple(o["key"] for o in OUTCOMES)
CONFLICT_ORDER = (
{
"key": "operator_stance",
"title": "A stance the operator stated",
"test": (
"One side follows a stance the operator stated — a rule, a "
"preference, a recorded decision. It wins over anything inferred. "
"Name the stance in the evidence."
),
"fold_as": "alternative",
},
{
"key": "covers_failure",
"title": "Covers a recorded failure",
"test": (
"One side covers a failure that is on record — an incident, an "
"issue, a lesson — and the other does not. Name the failure in the "
"evidence. The other side becomes owed."
),
"fold_as": "trap",
},
{
"key": "split_by_condition",
"title": "Both right, under different conditions",
"test": (
"Each side is right under a condition the other does not meet. The "
"canon splits: each approach is canon where its condition holds. "
"State both conditions."
),
"fold_as": "condition",
},
{
"key": "most_recent_complete",
"title": "Most recently verified and most complete",
"test": (
"None of the above decides. The side verified most recently, and "
"covering the most, wins. Name the verification in the evidence."
),
"fold_as": "alternative",
},
)
CONFLICT_KEYS = tuple(c["key"] for c in CONFLICT_ORDER)
_GROUND = {c["key"]: c for c in CONFLICT_ORDER}
_OPEN_TASK = (TaskStatus.todo.value, TaskStatus.in_progress.value)
def _clean(items) -> list[str]:
return [str(e).strip() for e in items or [] if str(e).strip()]
# --- the pure checks ---------------------------------------------------------------
def assessment_problems(*, outcome: str, reason: str, evidence: list | None) -> list[str]:
"""What an assessment is missing before it can be recorded. Pure.
Every outcome needs a reason — it is what the next assessment of a
similar idea is checked against. `adopted` also needs evidence naming
where the project does it.
"""
if outcome not in OUTCOME_KEYS:
return [f"outcome must be one of: {', '.join(OUTCOME_KEYS)}"]
problems = []
if not (reason or "").strip():
problems.append({
"exempt": "exempt needs a reason naming the fact that makes 'when it applies' false here",
"variant": "variant needs a reason naming the fact about this project the canon missed",
"adopted": "adopted needs a reason",
"owed": "owed needs a reason naming the gap",
}[outcome])
if outcome == "adopted" and not _clean(evidence):
problems.append("adopted needs evidence naming where the project does it")
return problems
def conflict_problems(
*, ground: str, grounds_checked: dict | None, evidence: list | None,
conditions: dict | None, fold: str,
) -> list[str]:
"""What a conflict resolution is missing before it can be recorded. Pure.
The order is enforced, not suggested: every ground ABOVE the deciding one
must carry a sentence saying why it did not decide. Grounds 1, 2 and 4
each need evidence (the stance, the failure, the verification); a split
needs both conditions; and the losing side's reasoning is required,
because it is folded into the idea rather than dropped.
"""
if ground not in CONFLICT_KEYS:
return [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"]
checked = grounds_checked or {}
problems = [
f"'{k}' comes before '{ground}' in the conflict order — say why it did not "
f"decide (grounds_checked['{k}'])"
for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]
if not str(checked.get(k) or "").strip()
]
if ground == "split_by_condition":
conds = conditions or {}
if not str(conds.get("canon") or "").strip() or not str(conds.get("other") or "").strip():
problems.append(
"a split needs both conditions: conditions={'canon': …, 'other': …}")
elif not _clean(evidence):
problems.append({
"operator_stance": "name the operator's stance in evidence (a rule, a preference, a decision)",
"covers_failure": "name the recorded failure in evidence (an incident, an issue, a lesson)",
"most_recent_complete": "name the verification in evidence (a CI run, a device check, a date)",
}[ground])
if not (fold or "").strip():
problems.append(
"the losing side's reasoning is folded into the idea, never dropped — `fold` is required")
return problems
def needs_recheck(row_status: str, row_version: int | None, idea_status: str,
idea_version: int) -> bool:
"""An answer given against a canon version that is not the current one.
Unassessed rows have nothing to recheck; a retired idea asks nothing."""
return (
idea_status == "canon" and row_status != "unassessed"
and row_version is not None and row_version != idea_version
)
def _row_state(row: FamilyAdoption) -> dict:
"""An answer as a decision's before/after. No ids (the #3182 trap): the
owed task is the row's column, not part of the snapshot."""
return {"status": row.status, "reason": row.reason or "", "canon_version": row.canon_version}
# --- reads -------------------------------------------------------------------------
async def _row(session, project_id: int, idea_id: int) -> FamilyAdoption | None:
return (await session.execute(
select(FamilyAdoption).where(
FamilyAdoption.project_id == project_id, FamilyAdoption.idea_id == idea_id)
)).scalars().first()
async def _is_member(session, project_id: int, idea_id: int) -> bool:
return bool(await session.scalar(
select(func.count()).select_from(ProjectPlatform)
.join(FamilyIdeaPlatform, FamilyIdeaPlatform.platform_id == ProjectPlatform.platform_id)
.where(
ProjectPlatform.project_id == project_id,
FamilyIdeaPlatform.note_id == idea_id,
ProjectPlatform.state.in_(family_svc.MEMBER_STATES),
)
))
async def _references(session, idea_id: int) -> list[dict]:
rows = (await session.execute(
select(Note)
.join(FamilyIdeaReference, FamilyIdeaReference.snippet_id == Note.id)
.where(FamilyIdeaReference.idea_id == idea_id, Note.deleted_at.is_(None))
.order_by(Note.id.asc())
)).scalars().all()
return [
{"id": n.id, "title": n.title,
"language": str((n.data or {}).get("language") or "").strip().lower()}
for n in rows
]
async def _project_languages(session, project_id: int) -> list[str]:
"""The languages of the snippets recorded in a project, most used first —
what "the reference for this project's language" is matched against."""
lang = Note.data["language"].astext
rows = (await session.execute(
select(func.lower(lang), func.count())
.where(Note.project_id == project_id, Note.note_type == "snippet",
Note.deleted_at.is_(None), lang.is_not(None), lang != "")
.group_by(func.lower(lang)).order_by(func.count().desc())
)).all()
return [r[0] for r in rows]
async def assessment_precedents(user_id: int, project_id: int, idea_id: int,
limit: int = 5) -> list[dict]:
"""What an assessment should be consistent with: the latest answer to
THIS idea in each other project the caller can read, then this project's
latest answers to the ideas nearest this one by meaning."""
async with async_session() as session:
same = (await session.execute(
select(func.max(FamilyDecision.id))
.where(FamilyDecision.idea_id == idea_id, FamilyDecision.action == "assess",
FamilyDecision.project_id.is_not(None),
FamilyDecision.project_id != project_id)
.group_by(FamilyDecision.project_id)
)).scalars().all()
near = await family_svc.nearest_ideas(user_id, idea_id, limit=3)
near_ids = [n.id for _, n in near]
async with async_session() as session:
mine = (await session.execute(
select(func.max(FamilyDecision.id))
.where(FamilyDecision.idea_id.in_(near_ids), FamilyDecision.action == "assess",
FamilyDecision.project_id == project_id)
.group_by(FamilyDecision.idea_id)
)).scalars().all() if near_ids else []
rows = (await session.execute(
select(FamilyDecision, Note.title, Project.title)
.join(Note, Note.id == FamilyDecision.idea_id)
.join(Project, Project.id == FamilyDecision.project_id)
.where(FamilyDecision.id.in_(list(same) + list(mine)))
)).all()
scores = {n.id: round(float(s), 3) for s, n in near}
out_same, out_near = [], []
for d, idea_title, project_title in rows:
if not await access.can_read_project(user_id, d.project_id):
continue
item = d.to_dict()
item.update({"idea_title": idea_title, "project_title": project_title})
if d.idea_id == idea_id:
item["relation"] = "same idea, another project"
out_same.append(item)
else:
item["relation"] = "nearest idea, this project"
item["similarity"] = scores.get(d.idea_id)
out_near.append(item)
out_same.sort(key=lambda x: -x["id"])
out_near.sort(key=lambda x: -(x.get("similarity") or 0))
return (out_same + out_near)[:limit]
async def adoption_matrix(
user_id: int, *, platform: str | None = None, project_id: int | None = None,
) -> dict:
"""Projects × canon ideas: every answer, and every project an idea
reaches that has none yet.
A cell exists where the project is on one of the idea's platforms, or
has an answer from before the idea's scope changed. A member project with
no row reads `unassessed` with `reached: False` — the promoter could not
write it, so nobody has been asked. Projects and ideas the caller cannot
read are left out.
"""
async with async_session() as session:
query = (
select(FamilyIdea, Note.title, Note.note_type, Note.status)
.join(Note, Note.id == FamilyIdea.note_id)
.where(FamilyIdea.status == "canon", Note.deleted_at.is_(None),
access.readable_notes_clause(user_id))
)
if platform:
query = query.where(FamilyIdea.note_id.in_(
select(FamilyIdeaPlatform.note_id)
.join(Platform, Platform.id == FamilyIdeaPlatform.platform_id)
.where(Platform.slug == platform)
))
ideas = (await session.execute(query.order_by(Note.title.asc()))).all()
idea_ids = [i.note_id for i, *_ in ideas]
idea_platforms: dict[int, dict[int, str]] = {i: {} for i in idea_ids}
for nid, pid, slug in (await session.execute(
select(FamilyIdeaPlatform.note_id, Platform.id, Platform.slug)
.join(Platform, Platform.id == FamilyIdeaPlatform.platform_id)
.where(FamilyIdeaPlatform.note_id.in_(idea_ids))
.order_by(Platform.order_index.asc(), Platform.slug.asc())
)).all():
idea_platforms[nid][pid] = slug
platform_ids = {pid for m in idea_platforms.values() for pid in m}
membership: dict[int, set[int]] = {}
for pid, plat in (await session.execute(
select(ProjectPlatform.project_id, ProjectPlatform.platform_id)
.where(ProjectPlatform.platform_id.in_(platform_ids),
ProjectPlatform.state.in_(family_svc.MEMBER_STATES))
)).all():
membership.setdefault(pid, set()).add(plat)
rows = (await session.execute(
select(FamilyAdoption).where(FamilyAdoption.idea_id.in_(idea_ids))
)).scalars().all()
project_ids = set(membership) | {r.project_id for r in rows}
if project_id:
project_ids &= {project_id}
projects = (await session.execute(
select(Project.id, Project.title)
.where(Project.id.in_(project_ids), Project.deleted_at.is_(None))
.order_by(Project.title.asc())
)).all()
task_ids = [r.owed_task_id for r in rows if r.owed_task_id]
tasks = {
t.id: t for t in (await session.execute(
select(Note).where(Note.id.in_(task_ids), Note.deleted_at.is_(None))
)).scalars().all()
} if task_ids else {}
readable = [(pid, title) for pid, title in projects
if await access.can_read_project(user_id, pid)]
by_pair = {(r.project_id, r.idea_id): r for r in rows}
idea_meta = {i.note_id: (i, title) for i, title, *_ in ideas}
cells = []
for pid, ptitle in readable:
for iid in idea_ids:
idea, ititle = idea_meta[iid]
row = by_pair.get((pid, iid))
reached = bool(membership.get(pid, set()) & set(idea_platforms[iid]))
if row is None and not reached:
continue
cell = {
"project_id": pid, "project_title": ptitle,
"idea_id": iid, "idea_title": ititle,
"idea_version": idea.canon_version,
"in_scope": reached,
"reached": row is not None,
"status": row.status if row else "unassessed",
"reason": (row.reason or "") if row else "",
"canon_version": row.canon_version if row else None,
"assessed_at": row.to_dict()["assessed_at"] if row else None,
"decided_via": row.decided_via if row else None,
"needs_recheck": bool(row) and needs_recheck(
row.status, row.canon_version, idea.status, idea.canon_version),
"owed_task": None,
}
task = tasks.get(row.owed_task_id) if row and row.owed_task_id else None
if task is not None:
cell["owed_task"] = {"id": task.id, "title": task.title, "status": task.status}
cells.append(cell)
seen_projects = {c["project_id"] for c in cells}
return {
"ideas": [
{
"note_id": i.note_id, "title": title, "note_type": note_type or "note",
"is_task": note_status is not None, "canon_version": i.canon_version,
"applies_when": i.applies_when or "",
"platforms": list(idea_platforms[i.note_id].values()),
}
for i, title, note_type, note_status in ideas
],
"projects": [
{"id": pid, "title": title,
"platforms": sorted({s for iid in idea_ids
for p, s in idea_platforms[iid].items()
if p in membership.get(pid, set())})}
for pid, title in readable if pid in seen_projects
],
"cells": cells,
"outcomes": list(OUTCOMES),
}
async def list_adoptions(
user_id: int, *, project_id: int | None = None, idea_id: int | None = None,
status: str | None = None, recheck_only: bool = False, platform: str | None = None,
) -> list[dict]:
"""The ledger as rows — the matrix's cells, filtered."""
matrix = await adoption_matrix(user_id, platform=platform, project_id=project_id)
return [
c for c in matrix["cells"]
if (not idea_id or c["idea_id"] == idea_id)
and (not status or c["status"] == status)
and (not recheck_only or c["needs_recheck"])
]
async def get_adoption(user_id: int, project_id: int, idea_id: int) -> dict:
"""Everything one assessment reads: the idea, this project's current
answer, the four outcomes, the precedents, and the reference
implementations with this project's languages."""
if not await access.can_read_project(user_id, project_id):
raise ValueError(f"project {project_id} not found")
idea = await family_svc.get_idea(user_id, idea_id)
if idea is None:
raise ValueError(f"#{idea_id} is not a family idea you can read")
rows = await list_adoptions(user_id, project_id=project_id, idea_id=idea_id)
async with async_session() as session:
refs = await _references(session, idea_id)
langs = await _project_languages(session, project_id)
idea.pop("decisions", None)
return {
"idea": idea,
"adoption": rows[0] if rows else None,
"outcomes": list(OUTCOMES),
"precedents": await assessment_precedents(user_id, project_id, idea_id),
"references": refs,
"project_languages": langs,
}
# --- the owed task -----------------------------------------------------------------
def _reference_line(refs: list[dict], langs: list[str], idea_id: int) -> str:
def fmt(r):
return f"#{r['id']} “{r['title']}”" + (f" ({r['language']})" if r["language"] else "")
if not refs:
return (f"No reference implementation is recorded yet — build from the idea's "
f"note, #{idea_id}.")
mine = [r for r in refs if r["language"] and r["language"] in langs]
if mine:
return "; ".join(fmt(r) for r in mine)
said = ", ".join(langs) if langs else "not yet known"
return (f"None is recorded in this project's language ({said}). The idea is what "
f"transfers; the ones that exist: {'; '.join(fmt(r) for r in refs)}.")
async def _matching_systems(session, project_id: int, note_ids: list[int]) -> list[int]:
"""The project's Systems matching the idea's: the same canonical area as
one the idea (or a reference) is filed under, or that very System when
the idea was written in this project."""
tagged = (await session.execute(
select(System.id, System.project_id, System.canonical_id)
.join(RecordSystem, RecordSystem.system_id == System.id)
.where(RecordSystem.note_id.in_(note_ids), System.deleted_at.is_(None))
)).all()
direct = [sid for sid, pid, _ in tagged if pid == project_id]
canonical = {cid for _, _, cid in tagged if cid is not None}
mapped = (await session.execute(
select(System.id).where(
System.project_id == project_id, System.canonical_id.in_(canonical),
System.deleted_at.is_(None))
.order_by(System.order_index.asc(), System.id.asc())
)).scalars().all() if canonical else []
return list(dict.fromkeys(direct + list(mapped)))
async def _file_owed_task(user_id: int, row_id: int, reason: str,
system_ids: list[int] | None) -> Note:
from scribe.services import notes as notes_svc
from scribe.services import systems as systems_svc
async with async_session() as session:
row = await session.get(FamilyAdoption, row_id)
idea = await session.get(FamilyIdea, row.idea_id)
note = await session.get(Note, row.idea_id)
refs = await _references(session, row.idea_id)
langs = await _project_languages(session, row.project_id)
platforms = await family_svc._platform_slugs(session, row.idea_id)
systems = system_ids or await _matching_systems(
session, row.project_id, [row.idea_id] + [r["id"] for r in refs])
project_id, idea_id, version = row.project_id, row.idea_id, idea.canon_version
body = (
f"Family idea #{idea_id} “{note.title}” is canon for "
f"{', '.join(platforms) or 'its platforms'}, applies to this project, and is "
f"not done here yet.\n\n"
f"**When it applies:** {idea.applies_when or '—'}\n\n"
f"**The gap:** {reason.strip()}\n\n"
f"**Start from:** {_reference_line(refs, langs, idea_id)}\n\n"
"Build the idea in this project's own language and conventions: what the "
"family shares is the idea, its traps and its checklist, not the code. When "
"it is done, assess it again as `adopted` (assess_family_adoption) with where "
"it lives as evidence; that closes this task. If it turns out not to apply, "
"or this project has a reason to depart that is a fact about itself, assess "
"it `exempt` or `variant` instead.\n\n"
f"_Filed by the family adoption ledger against canon version {version}._"
)
task = await notes_svc.create_note(
user_id, title=f"Adopt the family idea “{note.title}”"[:500], body=body,
project_id=project_id, status=TaskStatus.todo.value, task_kind="work",
)
if systems:
await systems_svc.set_record_systems(user_id, task.id, systems)
return task
async def _set_task_status(user_id: int, task: Note, status: str, log: str) -> None:
from scribe.services import notes as notes_svc
from scribe.services import task_logs
# The writer is checked; the write goes through the owner's door, which
# is the one that keeps versions, claims and the embedding in step.
await notes_svc.update_note(task.user_id, task.id, status=status)
await task_logs.create_log(user_id, task.id, log)
async def _sync_owed_task(user_id: int, row_id: int, *, reason: str,
system_ids: list[int] | None = None) -> dict | None:
"""Make the owed task agree with the answer. Idempotent: an `owed` row
with an open task, or a settled row with a closed one, is left alone.
A task the caller cannot write is left alone too, and said so."""
async with async_session() as session:
row = await session.get(FamilyAdoption, row_id)
task = await session.get(Note, row.owed_task_id) if row.owed_task_id else None
if task is not None and task.deleted_at is not None:
task = None
status, idea_id = row.status, row.idea_id
def ref(t: Note, s: str, action: str | None = None) -> dict:
out = {"id": t.id, "title": t.title, "status": s}
if action:
out["action"] = action
return out
if status == "owed":
if task is not None and task.status in _OPEN_TASK:
return ref(task, task.status)
if task is not None and await access.can_write_note(user_id, task.id):
await _set_task_status(
user_id, task, TaskStatus.todo.value,
f"Reopened: family idea #{idea_id} was assessed owed again — {reason}")
return ref(task, TaskStatus.todo.value, "reopened")
task = await _file_owed_task(user_id, row_id, reason, system_ids)
async with async_session() as session:
row = await session.get(FamilyAdoption, row_id)
row.owed_task_id = task.id
await session.commit()
return ref(task, task.status, "filed")
if task is None:
return None
if task.status not in _OPEN_TASK:
return ref(task, task.status)
if not await access.can_write_note(user_id, task.id):
return ref(task, task.status, "left open — no write access to the task")
new = TaskStatus.done.value if status == "adopted" else TaskStatus.cancelled.value
if status == "unassessed":
line = f"Family idea #{idea_id}'s owed answer was undone — {reason}"
else:
line = f"Family idea #{idea_id} was assessed {status} in this project — {reason}"
await _set_task_status(user_id, task, new, line)
return ref(task, new, "closed")
# --- writes -------------------------------------------------------------------------
async def _row_for_write(session, project_id: int, idea_id: int) -> FamilyAdoption:
row = await _row(session, project_id, idea_id)
if row is not None:
return row
if not await _is_member(session, project_id, idea_id):
raise ValueError(
f"project {project_id} is not on any of #{idea_id}'s platforms, so the idea "
"does not reach it — answer the project's platforms first if it should")
row = FamilyAdoption(project_id=project_id, idea_id=idea_id, status="unassessed")
session.add(row)
await session.flush()
return row
def _answer(row: FamilyAdoption, *, status: str, reason: str, version: int,
decided_via: str) -> None:
row.status = status
row.reason = reason.strip() or None
row.canon_version = version
row.assessed_at = family_svc._now()
row.decided_via = decided_via
row.updated_at = family_svc._now()
async def assess(
user_id: int, project_id: int, idea_id: int, *, outcome: str, reason: str,
evidence: list[str] | None = None, precedent_ids: list[int] | None = None,
system_ids: list[int] | None = None, decided_via: str = "agent",
) -> dict:
"""Record one project's answer to one canon idea.
Raises ValueError before writing anything when the answer is malformed
(assessment_problems), the caller cannot write the project, or the idea
is not canon or does not reach the project.
The same answer given again — same outcome, reason and canon version —
records nothing (`changed: False`); it still makes sure an owed answer
has an open task. The response carries the precedents consulted, and
`owed_task` when there is one.
"""
evidence = _clean(evidence)
problems = assessment_problems(outcome=outcome, reason=reason, evidence=evidence)
if problems:
raise ValueError("; ".join(problems))
if not await access.can_write_project(user_id, project_id):
raise ValueError(f"project {project_id} not found or no write access")
if not await access.can_read_note(user_id, idea_id):
raise ValueError(f"#{idea_id} is not a family idea you can read")
consulted = await assessment_precedents(user_id, project_id, idea_id)
named = await family_svc._existing_decision_ids(precedent_ids or [])
precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted]))
async with async_session() as session:
idea = await session.get(FamilyIdea, idea_id)
if idea is None or idea.status != "canon":
raise ValueError(f"#{idea_id} is not family canon — only canon is assessed")
row = await _row_for_write(session, project_id, idea_id)
before = _row_state(row)
after = {"status": outcome, "reason": reason.strip(), "canon_version": idea.canon_version}
changed = before != after
decision = None
if changed:
_answer(row, status=outcome, reason=reason, version=idea.canon_version,
decided_via=decided_via)
decision = family_svc._log(
session, idea_id=idea_id, project_id=project_id, action="assess",
reason=reason, before=before, after=_row_state(row),
evidence={"evidence": evidence}, precedent_ids=precedent_list,
decided_via=decided_via, user_id=user_id,
)
await session.commit()
if decision is not None:
await session.refresh(decision)
row_id = row.id
task = await _sync_owed_task(user_id, row_id, reason=reason, system_ids=system_ids)
rows = await list_adoptions(user_id, project_id=project_id, idea_id=idea_id)
return {
"changed": changed,
"adoption": rows[0] if rows else None,
"decision": decision.to_dict() if decision is not None else None,
"precedents": consulted,
"owed_task": task,
}
async def undo_assessment(user_id: int, target: FamilyDecision, *, reason: str,
decided_via: str = "agent") -> dict:
"""Reverse one project's answer, restoring the row it recorded as
`before`. Only the latest decision on that (idea, project) answer can be
undone. The owed task follows the restored answer."""
if not await access.can_write_project(user_id, target.project_id):
raise ValueError(f"decision {target.id} not found or no write access")
async with async_session() as session:
latest = (await family_svc._latest_pair_decisions(
session, {(target.idea_id, target.project_id)})).get((target.idea_id, target.project_id))
if latest != target.id:
raise ValueError(
f"decision {target.id} cannot be undone: decision {latest} came after it — "
"undo that one first")
if not family_svc._can_undo(target):
raise ValueError(f"decision {target.id} cannot be undone: it changed nothing")
row = await _row(session, target.project_id, target.idea_id)
if row is None:
raise ValueError(f"decision {target.id} cannot be undone: the answer it changed is gone")
before = _row_state(row)
prior = target.before or {"status": "unassessed", "reason": "", "canon_version": None}
row.status = prior["status"]
row.reason = (prior.get("reason") or "").strip() or None
row.canon_version = prior.get("canon_version")
if row.status == "unassessed":
row.assessed_at = None
row.decided_via = None
else:
row.assessed_at = family_svc._now()
row.decided_via = decided_via
row.updated_at = family_svc._now()
decision = family_svc._log(
session, idea_id=target.idea_id, project_id=target.project_id, action="undo",
reason=reason, before=before, after=_row_state(row),
evidence={"undid_action": target.action}, precedent_ids=[target.id],
decided_via=decided_via, user_id=user_id,
)
await session.commit()
await session.refresh(decision)
row_id = row.id
task = await _sync_owed_task(user_id, row_id, reason=f"undo of decision {target.id}: {reason}")
async with async_session() as session:
idea = await session.get(FamilyIdea, target.idea_id)
row = await session.get(FamilyAdoption, row_id)
adoption = row.to_dict()
return {"idea": idea.to_dict(), "adoption": adoption,
"decision": decision.to_dict(), "owed_task": task}
async def set_references(user_id: int, idea_id: int, snippet_ids: list[int]) -> list[dict]:
"""Replace an idea's reference implementations — the snippets an owed
task points a project at, one per language. Each must be a snippet the
caller can read; the idea must be theirs to write."""
if not await access.can_write_note(user_id, idea_id):
raise ValueError(f"note {idea_id} not found or no write access")
wanted = list(dict.fromkeys(int(i) for i in snippet_ids or [] if i))
async with async_session() as session:
if await session.get(FamilyIdea, idea_id) is None:
raise ValueError(f"#{idea_id} is not a family idea")
found = {n.id: n for n in (await session.execute(
select(Note).where(Note.id.in_(wanted), Note.deleted_at.is_(None))
)).scalars().all()} if wanted else {}
bad = [i for i in wanted
if i not in found or (found[i].note_type or "") != "snippet"]
if bad:
raise ValueError(f"not a snippet: {', '.join(map(str, bad))}")
for i in wanted:
if not await access.can_read_note(user_id, i):
raise ValueError(f"not a snippet: {i}")
current = set((await session.execute(
select(FamilyIdeaReference.snippet_id).where(FamilyIdeaReference.idea_id == idea_id)
)).scalars().all())
for sid in current - set(wanted):
await session.delete(await session.get(FamilyIdeaReference, (idea_id, sid)))
for sid in wanted:
if sid not in current:
session.add(FamilyIdeaReference(idea_id=idea_id, snippet_id=sid))
await session.commit()
return await _references(session, idea_id)
def _fold_section(*, ground: str, fold: str, conditions: dict | None,
canon_title: str, other_title: str, when: str) -> str:
g = _GROUND[ground]
if g["fold_as"] == "condition":
conds = conditions or {}
return (
f"### When {conds['other'].strip()} — {other_title}'s approach\n\n"
f"{fold.strip()}\n\n"
f"When {conds['canon'].strip()}, {canon_title}'s approach above is the canon. "
f"_(Family conflict, {when}: {g['title'].lower()}.)_"
)
heading = "Trap" if g["fold_as"] == "trap" else "Alternative"
return (
f"### {heading} — {other_title}'s approach\n\n{fold.strip()}\n\n"
f"_(Family conflict, {when}: decided by {g['title'].lower()}; "
f"{canon_title}'s approach is the canon.)_"
)
async def resolve_conflict(
user_id: int, idea_id: int, *, canon_project_id: int, other_project_id: int,
ground: str, grounds_checked: dict | None, fold: str, evidence: list[str] | None,
reason: str, conditions: dict | None = None, precedent_ids: list[int] | None = None,
decided_via: str = "agent",
) -> dict:
"""Settle two projects that solve the same canon idea differently, by
the conflict order.
`canon_project_id` is the side whose approach the idea's note states
after this — if that is not what the note says now, rewrite the note
first (update_note); the note is the canon. `other_project_id` is the
side that loses, or in a split, the side whose condition is the branch.
Effects, in order: the losing side's reasoning (`fold`) is appended to
the idea's note — as a trap, an alternative, or the branch for its
condition; the version moves (a `revise` decision carrying the ground and
the grounds checked above it); the canon side is answered `adopted`, and
the other side `owed` (with a task filed) or, in a split, `adopted` under
its condition. Each answer is its own `assess` decision, naming the
revision as its precedent.
"""
from scribe.services import notes as notes_svc
evidence = _clean(evidence)
problems = conflict_problems(ground=ground, grounds_checked=grounds_checked,
evidence=evidence, conditions=conditions, fold=fold)
if canon_project_id == other_project_id:
problems.append("a conflict is between two different projects")
if not (reason or "").strip():
problems.append("a resolution needs a reason")
if problems:
raise ValueError("; ".join(problems))
if not await access.can_write_note(user_id, idea_id):
raise ValueError(f"note {idea_id} not found or no write access")
for pid in (canon_project_id, other_project_id):
if not await access.can_write_project(user_id, pid):
raise ValueError(f"project {pid} not found or no write access")
named = await family_svc._existing_decision_ids(precedent_ids or [])
consulted = await family_svc.precedents(user_id, idea_id)
precedent_list = list(dict.fromkeys(named + [p["id"] for p in consulted]))
async with async_session() as session:
idea = await session.get(FamilyIdea, idea_id)
if idea is None or idea.status != "canon":
raise ValueError(f"#{idea_id} is not family canon — only canon has conflicts")
for pid in (canon_project_id, other_project_id):
if await _row(session, pid, idea_id) is None and not await _is_member(session, pid, idea_id):
raise ValueError(f"#{idea_id} does not reach project {pid}")
titles = dict((await session.execute(
select(Project.id, Project.title)
.where(Project.id.in_([canon_project_id, other_project_id]))
)).all())
note = await session.get(Note, idea_id)
owner, body = note.user_id, note.body or ""
# The losing side's reasoning goes into the canon FIRST: whatever fails
# after this, the reasoning is not dropped.
section = _fold_section(
ground=ground, fold=fold, conditions=conditions,
canon_title=titles[canon_project_id], other_title=titles[other_project_id],
when=family_svc._now().date().isoformat(),
)
await notes_svc.update_note(owner, idea_id, body=f"{body.rstrip()}\n\n{section}\n")
split = ground == "split_by_condition"
other_outcome = "adopted" if split else "owed"
g = _GROUND[ground]
async with async_session() as session:
idea = await session.get(FamilyIdea, idea_id)
before = await family_svc._snapshot(session, idea)
idea.canon_version = await family_svc.next_version(session, idea)
idea.updated_at = family_svc._now()
await session.flush()
revision = family_svc._log(
session, idea_id=idea_id, action="revise", reason=reason, before=before,
after=await family_svc._snapshot(session, idea),
evidence={
"conflict": {
"ground": ground,
"grounds_checked": {k: str((grounds_checked or {}).get(k) or "").strip()
for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]},
"canon": titles[canon_project_id],
"other": titles[other_project_id],
"folded_as": g["fold_as"],
"conditions": conditions if split else None,
},
"evidence": evidence,
},
precedent_ids=precedent_list, decided_via=decided_via, user_id=user_id,
)
await session.flush()
row_ids = {}
answers = (
(canon_project_id, "adopted",
f"Canon after a family conflict ({g['title'].lower()}): {reason.strip()}"),
(other_project_id, other_outcome,
(f"Canon where {conditions['other'].strip()} (split by condition): {reason.strip()}"
if split else
f"Lost a family conflict ({g['title'].lower()}) — adopt the canon: {reason.strip()}")),
)
for pid, outcome, why in answers:
row = await _row_for_write(session, pid, idea_id)
row_before = _row_state(row)
_answer(row, status=outcome, reason=why, version=idea.canon_version,
decided_via=decided_via)
family_svc._log(
session, idea_id=idea_id, project_id=pid, action="assess", reason=why,
before=row_before, after=_row_state(row), evidence={"evidence": evidence},
precedent_ids=[revision.id], decided_via=decided_via, user_id=user_id,
)
row_ids[pid] = row.id
await session.commit()
await session.refresh(revision)
task = await _sync_owed_task(user_id, row_ids[other_project_id], reason=answers[1][2])
await _sync_owed_task(user_id, row_ids[canon_project_id], reason=answers[0][2])
return {
"idea": idea.to_dict(),
"decision": revision.to_dict(),
"folded_as": g["fold_as"],
"adoptions": await list_adoptions(user_id, idea_id=idea_id),
"owed_task": task,
}
+186
View File
@@ -0,0 +1,186 @@
"""The adoption ledger without a database (milestone 463 step 4).
The parts that decide are pure and pinned here: what an assessment needs
before it can be recorded, what each ground of the conflict order needs —
including that every ground ABOVE the deciding one was said not to apply —
when an answer needs a recheck, how a losing side is folded into the note,
and which reference an owed task points at. Beside them: the outcomes and
the order the agent is told are the ones enforced. The state machine against
Postgres is in tests/test_integration_family_adoption.py.
"""
from __future__ import annotations
import pytest
from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS
from scribe.services.family_adoption import (
CONFLICT_KEYS, CONFLICT_ORDER, OUTCOME_KEYS, _fold_section, _reference_line,
assessment_problems, conflict_problems, needs_recheck,
)
from tests.helpers import tool_doc
# --- what an assessment needs ------------------------------------------------------
@pytest.mark.parametrize("outcome", OUTCOME_KEYS)
def test_every_outcome_needs_a_reason(outcome):
problems = assessment_problems(outcome=outcome, reason=" ", evidence=["src/x.py"])
assert len(problems) == 1 and outcome in problems[0]
def test_adopted_needs_evidence_naming_where():
assert assessment_problems(outcome="adopted", reason="done", evidence=[]) == [
"adopted needs evidence naming where the project does it"]
assert assessment_problems(outcome="adopted", reason="done", evidence=[" "]) != []
assert assessment_problems(outcome="adopted", reason="done", evidence=["ci run 12"]) == []
@pytest.mark.parametrize("outcome", ("exempt", "variant", "owed"))
def test_the_other_outcomes_need_no_evidence(outcome):
assert assessment_problems(outcome=outcome, reason="a fact", evidence=None) == []
def test_an_unknown_outcome_is_refused_by_name():
problems = assessment_problems(outcome="ignored", reason="x", evidence=[])
assert problems and all(k in problems[0] for k in OUTCOME_KEYS)
# --- the conflict order, branch by branch ----------------------------------------
def _checked(upto: str) -> dict:
return {k: f"{k} did not decide" for k in CONFLICT_KEYS[:CONFLICT_KEYS.index(upto)]}
GOOD = {
"operator_stance": dict(evidence=["rule: one keystore per app"]),
"covers_failure": dict(evidence=["incident #12: update refused, signature mismatch"]),
"split_by_condition": dict(conditions={"canon": "the app self-updates",
"other": "a store installs it"}),
"most_recent_complete": dict(evidence=["verified on a device 2026-10-01"]),
}
@pytest.mark.parametrize("ground", CONFLICT_KEYS)
def test_each_ground_holds_when_its_needs_are_met(ground):
kw = dict(evidence=None, conditions=None, **{**GOOD[ground]})
assert conflict_problems(ground=ground, grounds_checked=_checked(ground),
fold="their reasoning", **kw) == []
@pytest.mark.parametrize("ground", CONFLICT_KEYS[1:])
def test_a_ground_is_refused_while_any_ground_above_it_is_unanswered(ground):
above = CONFLICT_KEYS[:CONFLICT_KEYS.index(ground)]
for skipped in above:
checked = {k: v for k, v in _checked(ground).items() if k != skipped}
problems = conflict_problems(ground=ground, grounds_checked=checked,
fold="x", **{"evidence": None, "conditions": None,
**GOOD[ground]})
assert len(problems) == 1 and f"'{skipped}'" in problems[0]
def test_the_first_ground_needs_nothing_checked_above_it():
assert conflict_problems(ground="operator_stance", grounds_checked=None, fold="x",
evidence=["rule 4"], conditions=None) == []
@pytest.mark.parametrize("ground", ("operator_stance", "covers_failure", "most_recent_complete"))
def test_grounds_one_two_and_four_need_their_evidence(ground):
problems = conflict_problems(ground=ground, grounds_checked=_checked(ground), fold="x",
evidence=[" "], conditions=None)
assert len(problems) == 1 and "evidence" in problems[0]
def test_a_split_needs_both_conditions():
for conds in (None, {"canon": "self-updates"}, {"canon": "", "other": "store"}):
problems = conflict_problems(ground="split_by_condition",
grounds_checked=_checked("split_by_condition"),
fold="x", evidence=None, conditions=conds)
assert len(problems) == 1 and "conditions" in problems[0]
def test_the_losing_reasoning_is_required():
problems = conflict_problems(ground="operator_stance", grounds_checked=None, fold=" ",
evidence=["rule 4"], conditions=None)
assert len(problems) == 1 and "never dropped" in problems[0]
def test_an_unknown_ground_names_the_order():
problems = conflict_problems(ground="seniority", grounds_checked=None, fold="x",
evidence=[], conditions=None)
assert problems == [f"ground must be one of, in order: {', '.join(CONFLICT_KEYS)}"]
# --- recheck -------------------------------------------------------------------------
@pytest.mark.parametrize("row_status,row_version,idea_status,idea_version,expected", [
("adopted", 1, "canon", 2, True),
("owed", 1, "canon", 2, True),
("adopted", 2, "canon", 2, False),
# An undone revision leaves answers AHEAD of the idea: still not agreeing.
("adopted", 3, "canon", 2, True),
("unassessed", None, "canon", 2, False),
("adopted", 1, "retired", 2, False),
])
def test_needs_recheck(row_status, row_version, idea_status, idea_version, expected):
assert needs_recheck(row_status, row_version, idea_status, idea_version) is expected
# --- folding the losing side into the note ------------------------------------------
@pytest.mark.parametrize("ground,heading", [
("operator_stance", "### Alternative — two's approach"),
("covers_failure", "### Trap — two's approach"),
("most_recent_complete", "### Alternative — two's approach"),
("split_by_condition", "### When a store installs it — two's approach"),
])
def test_the_losing_side_is_folded_as_its_ground_says(ground, heading):
text = _fold_section(ground=ground, fold="Their reasoning.",
conditions=GOOD["split_by_condition"]["conditions"],
canon_title="one", other_title="two", when="2026-10-06")
assert text.startswith(heading)
assert "Their reasoning." in text and "one's approach" in text
# --- the reference an owed task points at -----------------------------------------
REFS = [
{"id": 10, "title": "Update installer", "language": "kotlin"},
{"id": 11, "title": "Update installer", "language": "dart"},
]
def test_the_reference_in_the_projects_language_is_named():
line = _reference_line(REFS, ["dart"], idea_id=5)
assert "#11" in line and "#10" not in line
def test_no_reference_in_the_language_names_the_ones_that_exist():
line = _reference_line(REFS, ["python"], idea_id=5)
assert "python" in line and "#10" in line and "#11" in line
def test_no_reference_at_all_points_at_the_idea():
assert "#5" in _reference_line([], ["python"], idea_id=5)
# --- the product text and the doors ---------------------------------------------------
def test_the_outcomes_the_agent_is_told_are_the_ones_enforced():
"""Rule 119: the outcomes and the order are product text. The tool
docstrings must name every outcome and every ground the service checks,
in the service's order."""
assess_doc = tool_doc("scribe.mcp.tools.family", "assess_family_adoption")
for n, key in enumerate(OUTCOME_KEYS, start=1):
assert f"{n}. {key} —" in assess_doc, f"outcome {n} is not taught as {key}"
resolve_doc = tool_doc("scribe.mcp.tools.family", "resolve_family_conflict")
for n, key in enumerate(CONFLICT_KEYS, start=1):
assert f"{n}. {key} —" in resolve_doc, f"ground {n} is not taught as {key}"
assert [c["fold_as"] for c in CONFLICT_ORDER] == ["alternative", "trap", "condition", "alternative"]
def test_every_adoption_tool_is_classified():
reads = {"get_family_adoption", "list_family_adoptions"}
writes = {"assess_family_adoption", "resolve_family_conflict", "revise_family_idea",
"set_family_references"}
assert reads <= _READ_ONLY_TOOLS
assert writes <= _WRITE_TOOLS
+389
View File
@@ -0,0 +1,389 @@
"""The adoption ledger against real Postgres (milestone 463 step 4).
What the unit lane can't show: an answer moves the row and logs exactly one
decision; an owed answer files its task in the OWING project, tagged to the
System matching the idea's, and the task follows the answer from there; a
version bump leaves every older answer reading as needing a recheck; each
branch of the conflict order folds the losing side into the note and settles
both rows; and the same assessment given twice records nothing the second
time.
Precedent search ranks by meaning and the lane has no embedding model, so
the search is stubbed to "nothing similar" (`_no_meaning`). The same-idea
precedents — another project's answer to this idea — need no embedder, and
are tested here.
"""
from unittest.mock import AsyncMock, patch
import pytest
import pytest_asyncio
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.canonical_system import CanonicalSystem
from scribe.models.family import FamilyAdoption, FamilyDecision, FamilyIdea, Platform, ProjectPlatform
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.system import RecordSystem, System
from scribe.models.task_log import TaskLog
from scribe.models.user import User
from scribe.services import family as family_svc
from scribe.services import family_adoption as adoption_svc
from tests.helpers import ensure_user
pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")]
OWNER = "family_adoption_owner"
OUTSIDER = "family_adoption_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 promotion 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():
"""Two Android apps and a Go service. The idea is a note in the first app,
filed under a System mapped to a canonical area; the second app has its
own System mapped to the same area, and one that is not. Promoted, so
both apps hold an `unassessed` row."""
android, go = await _platform_id("android-app"), await _platform_id("go")
async with async_session() as s:
canonical = await s.scalar(select(CanonicalSystem.id).where(
CanonicalSystem.deleted_at.is_(None)).order_by(CanonicalSystem.id).limit(1))
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")
s.add_all([a, b, c])
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="declared"),
ProjectPlatform(project_id=c.id, platform_id=go, state="declared"),
])
a_sys = System(user_id=owner.id, project_id=a.id, name="Release", canonical_id=canonical)
b_sys = System(user_id=owner.id, project_id=b.id, name="Shipping", canonical_id=canonical)
b_other = System(user_id=owner.id, project_id=b.id, name="Unrelated")
pattern = Note(user_id=owner.id, project_id=a.id, title="Signed APK lane",
body="One keystore, two channels, in-place update.")
s.add_all([a_sys, b_sys, b_other, pattern])
await s.flush()
s.add(RecordSystem(note_id=pattern.id, system_id=a_sys.id))
await s.commit()
ids = {"owner": owner.id, "outsider": outsider.id, "a": a.id, "b": b.id, "c": c.id,
"note": pattern.id, "b_sys": b_sys.id, "b_other": b_other.id}
out = await family_svc.promote(
ids["owner"], ids["note"], applies_when="any Android app that ships its own APK",
platforms=["android-app"], criteria=CRITERIA,
evidence=["CI green on the release lane"], reason="proven, stated for the platform",
)
assert out["promoted"] is True
return ids
async def _assess(f, project: str, outcome: str, reason: str = "the gap", **kw):
if outcome == "adopted":
kw.setdefault("evidence", ["app/build.gradle: the release lane"])
return await adoption_svc.assess(f["owner"], f[project], f["note"],
outcome=outcome, reason=reason, **kw)
async def _row(f, project: str) -> FamilyAdoption:
async with async_session() as s:
return (await s.execute(select(FamilyAdoption).where(
FamilyAdoption.project_id == f[project], FamilyAdoption.idea_id == f["note"]))).scalars().one()
async def _task(task_id: int) -> Note:
async with async_session() as s:
return await s.get(Note, task_id)
async def _decisions(f, project: str) -> list[FamilyDecision]:
async with async_session() as s:
return list((await s.execute(select(FamilyDecision).where(
FamilyDecision.idea_id == f["note"], FamilyDecision.project_id == f[project])
.order_by(FamilyDecision.id))).scalars().all())
# --- each state change ----------------------------------------------------------------
@pytest.mark.parametrize("outcome", ("adopted", "variant", "exempt"))
async def test_an_answer_moves_the_row_and_logs_one_decision(family, outcome):
out = await _assess(family, "b", outcome, reason="a fact about android two")
row = await _row(family, "b")
assert (row.status, row.reason, row.canon_version, row.decided_via) == (
outcome, "a fact about android two", 1, "agent")
assert row.assessed_at is not None and row.owed_task_id is None
[decision] = await _decisions(family, "b")
assert decision.action == "assess"
assert decision.before == {"status": "unassessed", "reason": "", "canon_version": None}
assert decision.after["status"] == outcome
assert out["changed"] is True and out["owed_task"] is None
async def test_owed_files_a_task_in_the_owing_project_tagged_to_the_matching_system(family):
out = await _assess(family, "b", "owed", reason="no in-place update yet")
row = await _row(family, "b")
assert row.status == "owed" and row.owed_task_id == out["owed_task"]["id"]
task = await _task(row.owed_task_id)
# Filed where the work is owed — never in the idea's own project.
assert task.project_id == family["b"] and task.status == "todo"
assert f"#{family['note']}" in task.body and "no in-place update yet" in task.body
assert "any Android app that ships its own APK" in task.body
async with async_session() as s:
tagged = set((await s.execute(select(RecordSystem.system_id).where(
RecordSystem.note_id == task.id))).scalars().all())
assert tagged == {family["b_sys"]}
async def test_the_owed_task_follows_the_answer(family):
await _assess(family, "b", "owed", reason="not built")
task_id = (await _row(family, "b")).owed_task_id
await _assess(family, "b", "variant", reason="installs through a managed store")
assert (await _task(task_id)).status == "cancelled"
out = await _assess(family, "b", "owed", reason="the store plan fell through")
assert out["owed_task"] == {"id": task_id, "title": (await _task(task_id)).title,
"status": "todo", "action": "reopened"}
await _assess(family, "b", "adopted", reason="built")
assert (await _task(task_id)).status == "done"
async with async_session() as s:
logs = (await s.execute(select(TaskLog.content).where(TaskLog.task_id == task_id)
.order_by(TaskLog.id))).scalars().all()
assert [("variant" in logs[0]), ("owed again" in logs[1]), ("adopted" in logs[2])] == [True] * 3
# One task across the whole life of the answer.
async with async_session() as s:
filed = (await s.execute(select(Note.id).where(
Note.project_id == family["b"], Note.title.like("Adopt the family idea%")))).scalars().all()
assert filed == [task_id]
async def test_the_same_assessment_twice_records_nothing_the_second_time(family):
first = await _assess(family, "b", "owed", reason="not built")
second = await _assess(family, "b", "owed", reason="not built")
assert first["changed"] is True and second["changed"] is False
assert second["decision"] is None
assert second["owed_task"]["id"] == first["owed_task"]["id"]
assert len(await _decisions(family, "b")) == 1
async def test_a_departure_without_a_reason_writes_nothing(family):
with pytest.raises(ValueError, match="exempt needs a reason"):
await _assess(family, "b", "exempt", reason=" ")
with pytest.raises(ValueError, match="adopted needs evidence"):
await _assess(family, "b", "adopted", evidence=[])
assert (await _row(family, "b")).status == "unassessed"
assert await _decisions(family, "b") == []
async def test_an_idea_that_does_not_reach_the_project_is_refused(family):
with pytest.raises(ValueError, match="not on any of"):
await _assess(family, "c", "exempt", reason="it is a Go service")
async def test_only_canon_is_assessed(family):
await family_svc.retire(family["owner"], family["note"], reason="superseded")
with pytest.raises(ValueError, match="not family canon"):
await _assess(family, "a", "adopted")
async def test_someone_who_cannot_write_the_project_cannot_answer_for_it(family):
with pytest.raises(ValueError, match="no write access"):
await adoption_svc.assess(family["outsider"], family["b"], family["note"],
outcome="exempt", reason="not mine to say")
async def test_another_projects_answer_is_recorded_as_precedent(family):
first = await _assess(family, "a", "adopted", reason="the source app")
second = await _assess(family, "b", "owed", reason="not built")
assert first["decision"]["id"] in second["decision"]["precedent_ids"]
assert second["precedents"][0]["relation"] == "same idea, another project"
assert second["precedents"][0]["project_title"] == "android one"
# --- recheck --------------------------------------------------------------------------
async def test_a_revision_leaves_every_older_answer_needing_a_recheck(family):
await _assess(family, "a", "adopted")
await _assess(family, "b", "exempt", reason="ships through a store")
out = await family_svc.revise(family["owner"], family["note"],
reason="the keystore now rotates", evidence=["incident"])
assert out["idea"]["canon_version"] == 2
rows = await adoption_svc.list_adoptions(family["owner"], idea_id=family["note"])
assert {r["project_title"]: r["needs_recheck"] for r in rows} == {
"android one": True, "android two": True}
assert [r["project_title"] for r in await adoption_svc.list_adoptions(
family["owner"], idea_id=family["note"], recheck_only=True)] == ["android one", "android two"]
await _assess(family, "a", "adopted", reason="rotation added")
rows = {r["project_title"]: r for r in await adoption_svc.list_adoptions(
family["owner"], idea_id=family["note"])}
assert rows["android one"]["needs_recheck"] is False
assert rows["android one"]["canon_version"] == 2
assert rows["android two"]["needs_recheck"] is True
async def test_a_revision_needs_canon_and_a_reason(family):
with pytest.raises(ValueError, match="needs a reason"):
await family_svc.revise(family["owner"], family["note"], reason="")
with pytest.raises(ValueError, match="at least one platform"):
await family_svc.revise(family["owner"], family["note"], reason="x", platforms=[])
# --- undo ------------------------------------------------------------------------------
async def test_undoing_an_owed_answer_restores_the_row_and_closes_its_task(family):
out = await _assess(family, "b", "owed", reason="not built")
task_id = out["owed_task"]["id"]
decisions = await family_svc.list_decisions(family["owner"], project_id=family["b"])
assert decisions[0]["undoable"] is True and decisions[0]["project_title"] == "android two"
await family_svc.undo(family["owner"], out["decision"]["id"], reason="assessed too early")
row = await _row(family, "b")
assert (row.status, row.reason, row.canon_version, row.assessed_at) == (
"unassessed", None, None, None)
assert (await _task(task_id)).status == "cancelled"
undo_row = (await _decisions(family, "b"))[-1]
assert undo_row.action == "undo" and undo_row.precedent_ids == [out["decision"]["id"]]
async def test_only_the_latest_answer_is_undoable(family):
first = await _assess(family, "b", "owed", reason="not built")
await _assess(family, "b", "adopted", reason="built")
with pytest.raises(ValueError, match="came after it"):
await family_svc.undo(family["owner"], first["decision"]["id"], reason="x")
# --- the conflict order -------------------------------------------------------------
def _checked(ground: str) -> dict:
keys = adoption_svc.CONFLICT_KEYS
return {k: f"{k} does not apply here" for k in keys[:keys.index(ground)]}
async def _resolve(f, ground: str, **kw):
kw.setdefault("evidence", ["named in the record"])
return await adoption_svc.resolve_conflict(
f["owner"], f["note"], canon_project_id=f["a"], other_project_id=f["b"],
ground=ground, grounds_checked=_checked(ground),
fold="Android two signs in CI with a throwaway key.", reason="settled", **kw)
@pytest.mark.parametrize("ground,heading,other_outcome", [
("operator_stance", "### Alternative — android two's approach", "owed"),
("covers_failure", "### Trap — android two's approach", "owed"),
("most_recent_complete", "### Alternative — android two's approach", "owed"),
])
async def test_a_side_that_loses_is_folded_in_and_owes_the_canon(family, ground, heading, other_outcome):
out = await _resolve(family, ground)
async with async_session() as s:
body = (await s.get(Note, family["note"])).body
idea = await s.get(FamilyIdea, family["note"])
assert heading in body and "throwaway key" in body
assert idea.canon_version == 2
assert out["decision"]["action"] == "revise"
assert out["decision"]["evidence"]["conflict"]["ground"] == ground
a, b = await _row(family, "a"), await _row(family, "b")
assert (a.status, a.canon_version) == ("adopted", 2)
assert (b.status, b.canon_version) == (other_outcome, 2)
assert (await _task(b.owed_task_id)).project_id == family["b"]
# Each answer names the revision as the decision it followed.
assert (await _decisions(family, "b"))[-1].precedent_ids == [out["decision"]["id"]]
async def test_a_split_makes_each_side_canon_under_its_condition(family):
out = await _resolve(family, "split_by_condition", evidence=None,
conditions={"canon": "the app updates itself",
"other": "a managed store installs it"})
async with async_session() as s:
body = (await s.get(Note, family["note"])).body
assert "### When a managed store installs it — android two's approach" in body
assert "When the app updates itself, android one's approach above is the canon" in body
a, b = await _row(family, "a"), await _row(family, "b")
assert (a.status, b.status) == ("adopted", "adopted")
assert b.owed_task_id is None and out["owed_task"] is None
async def test_a_ground_cannot_skip_the_order_and_nothing_is_written(family):
async with async_session() as s:
body_before = (await s.get(Note, family["note"])).body
with pytest.raises(ValueError, match="'operator_stance' comes before 'covers_failure'"):
await adoption_svc.resolve_conflict(
family["owner"], family["note"], canon_project_id=family["a"],
other_project_id=family["b"], ground="covers_failure", grounds_checked={},
fold="x", evidence=["incident"], reason="settled")
async with async_session() as s:
assert (await s.get(Note, family["note"])).body == body_before
assert (await s.get(FamilyIdea, family["note"])).canon_version == 1
# --- the matrix -----------------------------------------------------------------------
async def test_the_matrix_shows_every_member_and_who_has_not_been_asked(family):
android = await _platform_id("android-app")
async with async_session() as s:
late = Project(user_id=family["owner"], title="android three")
s.add(late)
await s.flush()
s.add(ProjectPlatform(project_id=late.id, platform_id=android, state="detected"))
await s.commit()
late_id = late.id
await _assess(family, "b", "owed", reason="not built")
matrix = await adoption_svc.adoption_matrix(family["owner"])
cells = {(c["project_title"]): c for c in matrix["cells"] if c["idea_id"] == family["note"]}
assert set(cells) == {"android one", "android two", "android three"}
assert cells["android two"]["owed_task"]["status"] == "todo"
assert cells["android three"]["reached"] is False and cells["android three"]["status"] == "unassessed"
assert [o["key"] for o in matrix["outcomes"]] == list(adoption_svc.OUTCOME_KEYS)
# A late member's first answer creates its row.
await adoption_svc.assess(family["owner"], late_id, family["note"],
outcome="exempt", reason="ships through a store")
one = await adoption_svc.adoption_matrix(family["owner"], project_id=late_id)
assert [c["status"] for c in one["cells"]] == ["exempt"]
# Filtered to a platform no canon idea is for, there is nothing to show.
assert (await adoption_svc.adoption_matrix(family["owner"], platform="go"))["ideas"] == []
async def test_the_matrix_hides_what_the_caller_cannot_read(family):
matrix = await adoption_svc.adoption_matrix(family["outsider"])
assert all(i["note_id"] != family["note"] for i in matrix["ideas"])
+10 -2
View File
@@ -1,4 +1,4 @@
"""Structural tests for the family blueprint (milestone 463 step 3) — every
"""Structural tests for the family blueprint (milestone 463 steps 3 and 4) — 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."""
@@ -18,12 +18,20 @@ def test_family_blueprint_routes_every_endpoint():
("/api/family/ideas/<int:note_id>/retire", "POST"),
("/api/family/decisions", "GET"),
("/api/family/decisions/<int:decision_id>/undo", "POST"),
("/api/family/matrix", "GET"),
):
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"):
for name in ("propose", "promote", "retire", "undo", "revise"):
params = inspect.signature(getattr(svc, name)).parameters
assert "user_id" in params and "decided_via" in params, name
def test_every_ledger_write_takes_the_caller_and_who_decided():
from scribe.services import family_adoption as svc
for name in ("assess", "resolve_conflict", "undo_assessment"):
params = inspect.signature(getattr(svc, name)).parameters
assert "user_id" in params and "decided_via" in params, name