From 75cefe60e4055c1d6af5e317926067df73aaac78 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 1 Oct 2026 15:46:27 -0400
Subject: [PATCH] feat(lessons): both records show the link in the web UI
(milestone 440 step 7, #4635)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The lesson page gains an "Instance of" panel that holds one of three answers.
Unjudged is the fall-through, so it is stated rather than left blank:
- the rule(s) it was judged an instance of;
- "No rule — ";
- "Not yet judged".
Suggested links show what they rest on (distinct situations, and projects
when more than one), with Confirm / Not an instance for a reader who can
write. Rejected links stay listed with their reason.
The rule slide-over lists the lessons that are instances of it, plus the
suggestions waiting on a judgment. Each entry links through to the other
record, and kind and state wear the existing .rule-chip.
The write check moves to utils/permission.ts. The copy on the snippet page
looked for "edit", which the server never sends, so shared editors saw a
read-only page (#4640 "The snippet page hid its edit controls from shared
editors"). Guards pin the client's unions and write levels to the
service's, the model's and access.py's own values.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/api/lessons.ts | 48 +++++-
frontend/src/api/rulebooks.ts | 5 +
.../components/rules/RuleEditorSlideOver.vue | 28 ++++
frontend/src/utils/permission.ts | 12 ++
frontend/src/views/LessonDetailView.vue | 158 +++++++++++++++++-
frontend/src/views/SnippetDetailView.vue | 6 +-
tests/test_lesson_rule_link_ui.py | 117 +++++++++++++
7 files changed, 368 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/utils/permission.ts
create mode 100644 tests/test_lesson_rule_link_ui.py
diff --git a/frontend/src/api/lessons.ts b/frontend/src/api/lessons.ts
index 4b66ee8..d3d7d02 100644
--- a/frontend/src/api/lessons.ts
+++ b/frontend/src/api/lessons.ts
@@ -1,6 +1,34 @@
import type { RecordUsage } from "@/types/usage";
-import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
+import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
+import type { RuleKind } from "@/api/rulebooks";
+
+/** Where a lesson stands on "which rule is this an instance of?"
+ * (`services/lesson_rules.py`: LINKED / NO_RULE / UNJUDGED). Three answers,
+ * not two: "looked at and found to stand alone" and "nobody has looked" are
+ * different facts, and the second is the one worth acting on. A rejected link
+ * alone leaves a lesson unjudged — "not that rule" does not say whether
+ * another one fits. */
+export type RuleJudgment = "linked" | "no_rule" | "unjudged";
+
+/** A link's state (`models/lesson_rule_link.py` LINK_STATES). */
+export type LinkState = "suggested" | "confirmed" | "rejected";
+
+/** One link from a lesson to a rule, in the reader's view: only rules the
+ * reader owns are listed (`rules_for_lessons`). */
+export interface LessonRuleLink {
+ id: number;
+ title: string;
+ kind: RuleKind;
+ /** `suggested` carries nothing in retrieval until it is judged; only
+ * `confirmed` changes what surfaces; `rejected` is kept so the pair is
+ * never proposed again. */
+ state: LinkState;
+ /** Why it was confirmed or rejected. */
+ note: string;
+ /** What a suggestion rests on — sent on suggested links only. */
+ evidence?: { situations: number; projects: number; co_surfaced: number };
+}
/** A lesson: a transferable insight, retrievable by the SITUATION it applies
* to rather than by its topic.
@@ -51,6 +79,13 @@ export interface Lesson {
updated_at: string | null;
systems?: { id: number; name: string }[];
usage?: RecordUsage;
+ /** The rules this lesson points at, confirmed first, then suggested, then
+ * rejected. Absent (with `rule_judgment`) when the links could not be read
+ * — which is "not attached", never "no rule". */
+ rules?: LessonRuleLink[];
+ rule_judgment?: RuleJudgment;
+ /** The "no rule fits" answer, present when that is the judgment. */
+ no_rule?: { why: string; judged_at: string | null };
/** Set when another user owns this record. */
shared?: boolean;
owner?: string | null;
@@ -128,6 +163,17 @@ export function updateLesson(
return apiPatch(`/api/lessons/${id}`, payload);
}
+/** Confirm or reject one lesson→rule link. Confirming also clears a "no rule
+ * fits" answer: the two cannot both be the current answer. */
+export function judgeLessonLink(
+ lessonId: number,
+ ruleId: number,
+ verdict: "confirm" | "reject",
+ note = "",
+): Promise {
+ return apiPut(`/api/lessons/${lessonId}/rules/${ruleId}`, { verdict, note });
+}
+
/** Trash, not erase — recoverable. `apiDelete` discards the body, which is the
* established shape here (snippets delete the same way): the batch id is in
* the response, but no caller has needed it and inventing a second delete
diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts
index 331bd34..16c3c25 100644
--- a/frontend/src/api/rulebooks.ts
+++ b/frontend/src/api/rulebooks.ts
@@ -1,6 +1,7 @@
import type { RecordUsage } from "@/types/usage";
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
+import type { LinkState } from "@/api/lessons";
/** How a rule reaches a session (milestone 307). */
@@ -82,6 +83,10 @@ export interface Rule {
/** Present only when the rule has them (the server omits empty keys). */
systems?: { id: number; name: string }[];
relations?: RuleRelation[];
+ /** The lessons that point at this rule (milestone 440) — the concrete
+ * situations judged instances of it, plus any suggested and awaiting a
+ * judgment. Readable lessons only; omitted when there are none. */
+ lessons?: { id: number; title: string; state: LinkState; note: string }[];
}
/**
diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue
index 19ebc16..d33b7f9 100644
--- a/frontend/src/components/rules/RuleEditorSlideOver.vue
+++ b/frontend/src/components/rules/RuleEditorSlideOver.vue
@@ -27,6 +27,14 @@ const expiresWhen = ref("");
const relations = computed(() => store.currentRule?.relations ?? []);
+// The lessons that point at this rule (milestone 440): confirmed instances
+// first, then suggestions waiting on a judgment. A rejected lesson does not
+// point at the rule, so it is not listed here — it stays readable on the
+// lesson, where the judgment was made.
+const lessons = computed(() =>
+ (store.currentRule?.lessons ?? []).filter((l) => l.state !== "rejected"),
+);
+
// The label a reader needs to judge an edge, not the stored token.
const RELATION_LABEL: Record = {
co_surfaces: { outgoing: "arrives with", incoming: "arrives with" },
@@ -279,6 +287,21 @@ watch(() => props.ruleId, load);
+
+
Lessons that are instances of it
+
+
+ {{ l.title }}
+ suggested
+ {{ l.note }}
+
+
+
+ The situations that keep proving this rule. A confirmed lesson brings the rule along
+ when it surfaces; a suggested one waits for a judgment on the lesson's page.
+