diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py
index 216fec8..226a4e4 100644
--- a/mcp-server/src/legal_mcp/services/db.py
+++ b/mcp-server/src/legal_mcp/services/db.py
@@ -1442,6 +1442,19 @@ CREATE TABLE IF NOT EXISTS drain_controls (
);
"""
+SCHEMA_V34_SQL = """
+-- decision_lessons review gate (INV-LRN1/G10): a lesson must be chair-APPROVED
+-- before it flows to the writer. The style panel writes proposals
+-- (review_status='proposed'); get_recent_decision_lessons (writer-consumed) returns
+-- only 'approved'. The chair approves/rejects in /training. Existing rows take the
+-- column default — per chair decision (2026-06-11) all pre-gate lessons reset to
+-- 'proposed' (nothing flows until re-approved).
+ALTER TABLE decision_lessons
+ ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'proposed';
+CREATE INDEX IF NOT EXISTS idx_decision_lessons_review
+ ON decision_lessons(review_status);
+"""
+
async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
async with pool.acquire() as conn:
@@ -1479,6 +1492,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
await conn.execute(SCHEMA_V31_SQL)
await conn.execute(SCHEMA_V32_SQL)
await conn.execute(SCHEMA_V33_SQL)
+ await conn.execute(SCHEMA_V34_SQL)
logger.info("Database schema initialized (v1-v33)")
@@ -2257,7 +2271,7 @@ async def list_decision_lessons(corpus_id: UUID) -> list[dict]:
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, style_corpus_id, lesson_text, category, source, "
- " applied_to_skill, created_by, created_at, updated_at "
+ " applied_to_skill, review_status, created_by, created_at, updated_at "
"FROM decision_lessons WHERE style_corpus_id = $1 "
"ORDER BY created_at DESC",
corpus_id,
@@ -2272,16 +2286,20 @@ async def add_decision_lesson(
category: str = "general",
source: str = "manual",
created_by: str = "chaim",
+ review_status: str = "proposed",
) -> dict:
+ # review_status gate (INV-LRN1/G10): panel-written lessons default to 'proposed'
+ # and do NOT flow to the writer until the chair approves. A lesson the chair
+ # authors directly is passed review_status='approved' by the caller.
pool = await get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"INSERT INTO decision_lessons "
- "(style_corpus_id, lesson_text, category, source, created_by) "
- "VALUES ($1, $2, $3, $4, $5) "
+ "(style_corpus_id, lesson_text, category, source, created_by, review_status) "
+ "VALUES ($1, $2, $3, $4, $5, $6) "
"RETURNING id, style_corpus_id, lesson_text, category, source, "
- " applied_to_skill, created_by, created_at, updated_at",
- corpus_id, lesson_text, category, source, created_by,
+ " applied_to_skill, review_status, created_by, created_at, updated_at",
+ corpus_id, lesson_text, category, source, created_by, review_status,
)
return dict(row) if row else {}
@@ -2292,6 +2310,7 @@ async def update_decision_lesson(
lesson_text: str | None = None,
category: str | None = None,
applied_to_skill: bool | None = None,
+ review_status: str | None = None,
) -> dict:
sets: dict = {}
if lesson_text is not None:
@@ -2300,6 +2319,8 @@ async def update_decision_lesson(
sets["category"] = category
if applied_to_skill is not None:
sets["applied_to_skill"] = applied_to_skill
+ if review_status is not None:
+ sets["review_status"] = review_status
if not sets:
return {"updated": False, "reason": "nothing to update"}
sets["updated_at"] = "now()" # sentinel — replaced inline below
@@ -2312,7 +2333,7 @@ async def update_decision_lesson(
row = await conn.fetchrow(
f"UPDATE decision_lessons SET {set_clause} WHERE id = $1 "
f"RETURNING id, style_corpus_id, lesson_text, category, source, "
- f" applied_to_skill, updated_at",
+ f" applied_to_skill, review_status, updated_at",
lesson_id, *values,
)
if not row:
@@ -2494,7 +2515,12 @@ async def get_methodology_overrides(category: str) -> dict:
async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "") -> list[dict]:
"""Per-decision learnings the chair/curator attached in /training (decision_lessons),
- so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first."""
+ so the writer consumes them too (T15). Prefers style/structure/lexicon, recent first.
+
+ Gate (INV-LRN1/G10): returns only CHAIR-APPROVED lessons (review_status='approved').
+ Panel-written proposals stay out of the writer's context until the chair approves
+ them in /training — the chair's review is the gate, not the panel's 2/2 vote.
+ """
pool = await get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
@@ -2502,7 +2528,8 @@ async def get_recent_decision_lessons(limit: int = 15, practice_area: str = "")
sc.decision_number, sc.practice_area
FROM decision_lessons dl
JOIN style_corpus sc ON sc.id = dl.style_corpus_id
- WHERE ($2 = '' OR sc.practice_area = $2)
+ WHERE dl.review_status = 'approved'
+ AND ($2 = '' OR sc.practice_area = $2)
ORDER BY dl.created_at DESC
LIMIT $1""",
limit, practice_area,
diff --git a/web-ui/src/components/training/lessons-tab.tsx b/web-ui/src/components/training/lessons-tab.tsx
index cc2ac88..baa3581 100644
--- a/web-ui/src/components/training/lessons-tab.tsx
+++ b/web-ui/src/components/training/lessons-tab.tsx
@@ -18,7 +18,9 @@
*/
import { useState } from "react";
-import { Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles } from "lucide-react";
+import {
+ Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles, Check, X, Undo2,
+} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -56,6 +58,13 @@ const SOURCE_BADGE: Record