fix(ia): IA גל-1 — סנכרון-cache + נתונים-שגויים + מחיקת-מתים (#130, X17)
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 13s

גל-1 מבקלוג #127 (docs/ia-audit-redesign.md §4) — תיקון מקומי, ללא הגירת-IA.
מקיים G2 בשכבת-ה-UI דרך INV-IA1/IA2/IA5/IA6 (docs/spec/X17).

א) פערי-סנכרון (INV-IA2 — mutation מבטל כל קורא):
- CAS-1/2: העלאת-DOCX/export מבטלים ['decision-blocks'] (מחוון source_of_truth)
- APR-1/4: פתרון/יצירת-הערה מבטלים ['chair','pending'] (תיבה+תג-סרגל)
- APR-5/ADM-2: אישור/batch הלכות מבטלים ['chair','pending']+['operations']
- APR-6/ADM-3: create/update/delete/upload פסיקה-חסרה מבטלים שניהם
- LRN-6: ComparePanel גוזר בחירה מהקורפוס המרוענן (אין POST ל-id מחוק → 404)
- LRN-8: מחיקת-קורפוס מבטלת רשימת-צ'אטים (chat שהתייתם לא נשאר עם קישור-קורפוס תקוע)
- LRN-10/MET-1/MET-8: promote מבטל גם lessons וגם methodology (LessonsTab+/methodology)

ב) נתונים-שגויים (INV-IA5 — סטטוס מגובה-צרכן):
- LRN-4: KPI "דפוסי סגנון" — הוסר היחס-השקרי "מתוך total_patterns" (שאילתות עצמאיות)
- LRN-5: findings_applied (דגל אינפורמטיבי-בלבד) → findings_approved (שער INV-LRN1 האמיתי)
- ADM-1: halacha_backlog שהוחזר ונזרק → מרונדר ב-/diagnostics, מצביע ל-/approvals (INV-IA1)
- ADM-6: מוני-סוכנים מסמנים "חלקי+" כשחברת-Paperclip לא נטענה
- APR-3: מכוסה ע"י APR-1 (count+sample מאותה שאילתה; הבעיה היתה staleness-cache)
- MET-6: עורך-צ'קליסטים מציג איזה case בוחר כל צ'קליסט (explainer-תחולה)
- ADM-5: ערך-Container מסומן "ממתין ל-redeploy" כש-Coolify≠Container

ג) מתים/jargon:
- PRE-2: הוסר GET /api/precedent-library/queue/pending (אפס צרכני-frontend)
- PRE-3/5: AuthorityBadge (binding/persuasive) מרונדר גם בחיפוש, לא רק בתור-הביקורת
- MET-5: הוסר ז'רגון T7/T15 מטקסט-העזר ב-/methodology (INV-IA6)

Invariants: מקיים INV-IA1/IA2/IA5/IA6 (X17), G2 (מקור-אמת יחיד בשכבת-UI), G10
(לא הוסר שום שער-אנושי — רק סנכרון/נתון/קוד-מת). שומר INV-LRN1.

בדיקות: py_compile web/app.py ✓ · tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109
unescaped-quote — קיים-מראש ב-main, מחוץ לסט-הממצאים). next build נכשל רק בגלל
symlink node_modules ב-worktree (Turbopack) — ה-build ב-Docker/CI תקין.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 20:47:32 +00:00
parent f1ea4fc00a
commit 36bae6c592
17 changed files with 209 additions and 25 deletions

View File

@@ -129,6 +129,57 @@ export default function DiagnosticsPage() {
</Card>
</div>
{/* Halacha review backlog (ADM-1, INV-IA5): render the human-gate
counter the backend returns; the action lives at /approvals
(INV-IA1 — this surface only points, never decides). */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">
<h2 className="text-navy text-lg mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
תור אישור הלכות
<Badge variant="outline" className="text-[0.7rem] tabular-nums">
{isPending ? "—" : (data?.halacha_backlog?.pending_review ?? 0)}
</Badge>
<Link
href="/approvals"
className="ms-auto text-[0.75rem] text-gold-deep hover:underline"
>
לאישור
</Link>
</h2>
{isPending ? (
<Skeleton className="h-12 w-full" />
) : (
<dl className="grid grid-cols-2 md:grid-cols-4 gap-y-2 gap-x-4">
<div className="space-y-0.5">
<dt className="text-[0.72rem] text-ink-muted">ממתינים (נקי)</dt>
<dd className="font-display font-bold text-navy text-xl tabular-nums">
{data?.halacha_backlog?.pending_clean ?? 0}
</dd>
</div>
<div className="space-y-0.5">
<dt className="text-[0.72rem] text-ink-muted">דורש תיקון</dt>
<dd className="font-display font-bold text-warn text-xl tabular-nums">
{data?.halacha_backlog?.pending_flagged ?? 0}
</dd>
</div>
<div className="space-y-0.5">
<dt className="text-[0.72rem] text-ink-muted">אושרו</dt>
<dd className="font-display font-bold text-success text-xl tabular-nums">
{data?.halacha_backlog?.approved ?? 0}
</dd>
</div>
<div className="space-y-0.5">
<dt className="text-[0.72rem] text-ink-muted">הוותיק ביותר</dt>
<dd className="text-sm text-ink-soft">
{formatRelativeTime(data?.halacha_backlog?.oldest_pending_at ?? null)}
</dd>
</div>
</dl>
)}
</CardContent>
</Card>
{/* Active tasks */}
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5">

View File

@@ -45,14 +45,14 @@ export default function MethodologyPage() {
<TabsContent value="transitions" className="mt-5">
<GenericMethodologyPanel
category="transition_phrases"
hint="ביטויי-מעבר של דפנה, מקובצים לפי תוצאה. עריכה כאן זורמת לכותב (T15). ערך = רשימת מחרוזות JSON."
hint="ביטויי-מעבר של דפנה, מקובצים לפי תוצאה. עריכה כאן זורמת לכותב. ערך = רשימת מחרוזות JSON."
/>
</TabsContent>
<TabsContent value="antipatterns" className="mt-5">
<GenericMethodologyPanel
category="anti_patterns"
hint="דפוסים שדפנה נמנעת מהם — נמדדים ע״י מדד מרחק-הסגנון (T7) ומוזרקים לכותב. ערך = {regex, note}."
hint="דפוסים שדפנה נמנעת מהם — נמדדים ע״י מדד מרחק-הסגנון ומוזרקים לכותב. ערך = {regex, note}."
/>
</TabsContent>
</Tabs>

View File

@@ -443,8 +443,23 @@ function LiveAgentsPanel() {
<h2 className="text-navy text-lg mb-0">סוכנים פעילים</h2>
{data ? (
<div className="flex items-center gap-2 text-[0.72rem]">
<Badge variant="default" className="font-normal">רצים {data.running}</Badge>
<Badge variant="secondary" className="font-normal">בתור {data.queued}</Badge>
{/* ADM-6 (INV-IA5): counts sum only the companies that loaded.
When a company errored, mark the totals as a floor ("+") so
the operator isn't shown a shrunken depth as if complete. */}
<Badge variant="default" className="font-normal">
רצים {data.running}{data.errors.length > 0 ? "+" : ""}
</Badge>
<Badge variant="secondary" className="font-normal">
בתור {data.queued}{data.errors.length > 0 ? "+" : ""}
</Badge>
{data.errors.length > 0 ? (
<span
className="text-warn"
title={`ספירה חלקית — חברות שלא נטענו: ${data.errors.join(" · ")}`}
>
חלקי
</span>
) : null}
</div>
) : null}
</div>

View File

@@ -87,11 +87,23 @@ export function EnvVarRow({
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[0.72rem] text-ink-muted w-20">Container:</span>
<span className="font-mono text-ink" dir="ltr">
{spec.container_value ?? <em className="text-ink-muted"> לא מוגדר </em>}
</span>
{/* ADM-5 (INV-IA5/INV-IA6): when Coolify ≠ Container the container is
running a stale value until a redeploy — say so in plain Hebrew
right here, not only via the top "Drift" badge. */}
{coolifyAvailable && spec.drift && (
<Badge
variant="outline"
className="text-[0.7rem] text-warn border-warn/40"
title="הערך נשמר ב-Coolify אך הקונטיינר עדיין מריץ את הקודם — נדרש redeploy"
>
ממתין ל-redeploy
</Badge>
)}
</div>
</div>

View File

@@ -30,6 +30,17 @@ const CHECKLIST_ORDER = [
"betterment_levy",
];
// MET-6 (INV-IA6/INV-IA5): which case selects each checklist — mirrors the
// server's get_content_checklist() routing (lessons.py:580-622) so the chair
// sees *when* a checklist is consumed, not five unlabelled types.
const CHECKLIST_APPLIES: Record<string, string> = {
licensing_substantive: "ברירת-מחדל — כל ערר רישוי שאינו נופל לקטגוריה אחרת",
licensing_threshold: "עררי רישוי בנושא סמכות / סף / סילוק-על-הסף / זכות-ערר",
licensing_property: "עררי רישוי בנושא תימוכין קנייניים / בעלות / הסכמת-דיירים",
tama38: 'עררים בנושא תמ"א 38 / חיזוק',
betterment_levy: "תיקי היטל השבחה (8xxx)",
};
type ChecklistItem = {
key: string;
label: string;
@@ -131,12 +142,19 @@ export function ContentChecklistsPanel() {
<Card className="border-rule">
<CardContent className="px-5 py-4 space-y-3">
<div className="flex items-center justify-between">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-navy">{current.label}</h3>
{CHECKLIST_APPLIES[current.key] && (
<p className="text-[0.72rem] text-ink-muted mt-0.5">
חל על: {CHECKLIST_APPLIES[current.key]}
</p>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setPreview(!preview)}
className="text-xs"
className="text-xs shrink-0"
>
{preview ? <EyeOff className="w-3.5 h-3.5 ml-1" /> : <Eye className="w-3.5 h-3.5 ml-1" />}
{preview ? "עריכה" : "תצוגה מקדימה"}

View File

@@ -13,6 +13,7 @@ import {
useLibrarySearch, type PracticeArea, type SearchHit,
} from "@/lib/api/precedent-library";
import { PRACTICE_AREAS, PRECEDENT_LEVELS } from "./practice-area";
import { AuthorityBadge } from "./halacha-meta";
function formatDate(iso: string | null) {
if (!iso) return "—";
@@ -32,6 +33,10 @@ function HalachaCard({ hit }: { hit: Extract<SearchHit, { type: "halacha" }> })
{hit.court && <span>· {hit.court}</span>}
{hit.decision_date && <span>· {formatDate(hit.decision_date)}</span>}
{hit.precedent_level && <span>· {hit.precedent_level}</span>}
{/* PRE-3/PRE-5 (INV-IA5): the derived authority (binding/persuasive)
rides on the wire but was dropped here — render it as in the review
tab so search shows the same provenance everywhere. */}
<AuthorityBadge authority={hit.authority} />
<span className="ms-auto tabular-nums">דירוג {hit.score.toFixed(2)}</span>
</div>
<p className="text-navy font-medium text-[0.95rem]" dir="rtl">

View File

@@ -118,7 +118,15 @@ export function ComparePanel() {
const { data: corpus, isPending } = useCorpus();
const [a, setA] = useState<string | null>(null);
const [b, setB] = useState<string | null>(null);
const cmp = useCompare(a, b);
// LRN-6 (INV-IA2): if a selected decision was deleted from the corpus on
// another surface, a cached selection would POST a stale id and 404. Derive
// the effective selection from the refreshed corpus instead of holding it in
// state — a deleted id resolves to null (no effect, no stale POST).
const ids = corpus ? new Set(corpus.map((c) => c.id)) : null;
const validA = a && (!ids || ids.has(a)) ? a : null;
const validB = b && (!ids || ids.has(b)) ? b : null;
const cmp = useCompare(validA, validB);
return (
<div className="space-y-6">
@@ -133,7 +141,7 @@ export function ComparePanel() {
</label>
<Select
disabled={isPending}
value={(slot === "a" ? a : b) ?? ""}
value={(slot === "a" ? validA : validB) ?? ""}
onValueChange={(v) => (slot === "a" ? setA(v) : setB(v))}
dir="rtl"
>
@@ -155,7 +163,7 @@ export function ComparePanel() {
</CardContent>
</Card>
{a && b && a === b && (
{validA && validB && validA === validB && (
<p className="text-ink-muted text-sm text-center">בחר שתי החלטות שונות</p>
)}
@@ -167,7 +175,7 @@ export function ComparePanel() {
</Card>
)}
{cmp.isPending && a && b && a !== b && (
{cmp.isPending && validA && validB && validA !== validB && (
<Skeleton className="h-60 w-full" />
)}

View File

@@ -83,7 +83,7 @@ function StatsCard() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Kpi label="ממצאי curator" value={data.total_findings} icon={<Sparkles className="w-4 h-4" />} />
<Kpi label="החלטות שנסקרו" value={`${data.decisions_with_findings}/${data.decisions_total}`} icon={<FileText className="w-4 h-4" />} />
<Kpi label="ממצאים שאומצו ל-SKILL" value={data.findings_applied} icon={<CheckCircle2 className="w-4 h-4" />} />
<Kpi label="ממצאים מאושרים (זורמים לכותב)" value={data.findings_approved} icon={<CheckCircle2 className="w-4 h-4" />} />
<Kpi label="ממוצע ממצאים להחלטה"
value={
data.decisions_with_findings > 0

View File

@@ -83,10 +83,13 @@ export function StyleReportPanel() {
label="ממוצע להחלטה"
value={`${(c.avg_chars / 1000).toFixed(1)}K`}
/>
{/* LRN-4 (INV-IA5): items.length (style_patterns freq>0) and
contribution.total_patterns are independent queries — the old
"X מתוך Y" framed a false subset. Show the surfaced count alone. */}
<KPICard
label="דפוסי סגנון"
value={String(data.signature_phrases.items.length)}
caption={`מתוך ${data.contribution.total_patterns} שחולצו`}
caption="דפוסים חוזרים שזוהו"
/>
</div>

View File

@@ -5,6 +5,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import { casesKeys } from "./cases";
import { decisionBlocksKeys } from "./decision-blocks";
export type ExportFile = {
filename: string;
@@ -80,6 +81,9 @@ export function useExportDocx(caseNumber: string) {
onSuccess: () => {
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
qc.invalidateQueries({ queryKey: casesKeys.detail(caseNumber) });
// CAS-2 (INV-IA2): export flips active_draft → source_of_truth='docx';
// the block viewer must re-fetch or its sync warning stays hidden.
qc.invalidateQueries({ queryKey: decisionBlocksKeys.case(caseNumber) });
},
});
}
@@ -104,6 +108,9 @@ export function useUploadDraft(caseNumber: string) {
qc.invalidateQueries({ queryKey: exportsKeys.list(caseNumber) });
qc.invalidateQueries({ queryKey: exportsKeys.activeDraft(caseNumber) });
qc.invalidateQueries({ queryKey: exportsKeys.bookmarks(caseNumber) });
// CAS-1 (INV-IA2): uploading a DOCX draft makes it the source-of-truth;
// refresh the block viewer so the divergence banner appears immediately.
qc.invalidateQueries({ queryKey: decisionBlocksKeys.case(caseNumber) });
},
});
}

View File

@@ -79,6 +79,9 @@ export function useCreateFeedback() {
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: feedbackKeys.all });
// APR-4 (INV-IA2): a new unresolved comment raises the chair-pending
// aggregate — keep /approvals + the nav badge in sync.
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
},
});
}
@@ -103,6 +106,9 @@ export function useResolveFeedback() {
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: feedbackKeys.all });
// APR-1/APR-4 (INV-IA2): resolving a comment lowers the chair-pending
// aggregate; refresh /approvals' counter + the nav badge (one source).
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
},
});
}

View File

@@ -5,6 +5,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "./client";
import { trainingKeys } from "./training";
import { methodologyKeys } from "./methodology";
export type DraftFinalPair = {
id: string;
@@ -111,6 +113,13 @@ export function usePromoteLearning(pairId: string) {
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: learningKeys.all });
// LRN-10 (INV-IA2): promote flips folded lessons' review_status, so the
// per-corpus LessonsTab must refetch (lessons key is corpus-scoped —
// invalidate the whole "lessons" prefix since promote is pair-scoped).
qc.invalidateQueries({ queryKey: [...trainingKeys.all, "lessons"] });
// MET-1 (INV-IA2): promote also writes discussion_rules + transition_
// phrases['universal'] — /methodology (staleTime 30s) would stay stale.
qc.invalidateQueries({ queryKey: methodologyKeys.all });
},
});
}

View File

@@ -158,6 +158,10 @@ export function useCreateMissingPrecedent() {
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
// APR-6 / ADM-3 (INV-IA2): a new open gap raises the chair-pending
// aggregate and the /operations missing_precedents counter.
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
qc.invalidateQueries({ queryKey: ["operations"] });
},
});
}
@@ -173,6 +177,10 @@ export function useUpdateMissingPrecedent() {
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
// APR-6 / ADM-3 (INV-IA2): a status edit (e.g. open→irrelevant) shifts
// the chair-pending aggregate and the /operations counter.
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
qc.invalidateQueries({ queryKey: ["operations"] });
},
});
}
@@ -187,6 +195,10 @@ export function useDeleteMissingPrecedent() {
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
// APR-6 / ADM-3 (INV-IA2): removing a gap lowers the chair-pending
// aggregate and the /operations counter.
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
qc.invalidateQueries({ queryKey: ["operations"] });
},
});
}
@@ -256,6 +268,10 @@ export function useUploadMissingPrecedent() {
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
qc.invalidateQueries({ queryKey: ["precedent-library"] });
// APR-6 / ADM-3 (INV-IA2): uploading closes the gap → chair-pending
// aggregate and the /operations missing_precedents counter both drop.
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
qc.invalidateQueries({ queryKey: ["operations"] });
},
});
}

View File

@@ -646,6 +646,10 @@ export function useUpdateHalacha() {
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
// APR-5 / ADM-2 (INV-IA2): approving/rejecting a halacha changes the
// chair-pending aggregate and the /operations halacha_review counter.
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
qc.invalidateQueries({ queryKey: ["operations"] });
},
});
}
@@ -666,6 +670,10 @@ export function useBatchReviewHalachot() {
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
// APR-5 / ADM-2 (INV-IA2): a batch review shifts the chair-pending
// aggregate and the /operations halacha_review counter.
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
qc.invalidateQueries({ queryKey: ["operations"] });
},
});
}

View File

@@ -18,9 +18,28 @@ export type DiagDoc = {
created_at: string | null;
};
/** Halacha review backlog (GAP-14 / INV-QA1 / G10) — human-gate visibility. */
export type HalachaBacklog = {
pending_review: number;
pending_clean: number;
pending_flagged: number;
approved: number;
rejected: number;
deferred: number;
published: number;
total: number;
reviewed_total: number;
oldest_pending_at: string | null;
throughput_24h: number;
throughput_7d: number;
};
export type Diagnostics = {
db_ok: boolean;
tables: Record<string, number | null>;
// ADM-1 (INV-IA5): the backend returns this human-gate counter; render it
// rather than silently dropping it. /approvals owns the action (INV-IA1).
halacha_backlog: HalachaBacklog;
failed_documents: DiagDoc[];
stuck_documents: DiagDoc[];
active_tasks: Array<{

View File

@@ -172,6 +172,10 @@ export function useDeleteCorpusEntry() {
onSuccess: () => {
qc.invalidateQueries({ queryKey: trainingKeys.corpus() });
qc.invalidateQueries({ queryKey: trainingKeys.report() });
// LRN-8 (INV-IA2): deleting a corpus row nulls style_corpus_id on any
// chat that referenced it — refresh the conversation list so those
// chats no longer show a stale corpus link.
qc.invalidateQueries({ queryKey: chatKeys.conversations() });
},
});
}
@@ -301,7 +305,10 @@ export type CuratorStats = {
total_findings: number;
decisions_with_findings: number;
decisions_total: number;
findings_applied: number;
// LRN-5 (INV-IA5): the count of curator lessons with review_status='approved'
// (the real INV-LRN1 writer gate) — replaces the old findings_applied, which
// counted the informative-only applied_to_skill flag.
findings_approved: number;
recent_findings: CuratorFinding[];
};

View File

@@ -1327,9 +1327,13 @@ async def get_curator_stats():
"WHERE source = 'curator'"
)
total_corpus = await conn.fetchval("SELECT count(*) FROM style_corpus")
applied = await conn.fetchval(
# LRN-5 (INV-IA5): count the *real* consumer-mapped gate — review_status
# 'approved' is what flows to the writer (INV-LRN1, #126). The old count
# of applied_to_skill was a KPI over an informative-only flag (LRN-1)
# that writes nowhere, so it reported adoption that never happened.
approved = await conn.fetchval(
"SELECT count(*) FROM decision_lessons "
"WHERE source = 'curator' AND applied_to_skill = true"
"WHERE source = 'curator' AND review_status = 'approved'"
)
# Last 10 curator findings — newest first
recent_rows = await conn.fetch(
@@ -1348,7 +1352,7 @@ async def get_curator_stats():
"total_findings": total_lessons or 0,
"decisions_with_findings": decisions_with_findings or 0,
"decisions_total": total_corpus or 0,
"findings_applied": applied or 0,
"findings_approved": approved or 0,
"recent_findings": [
{
"id": str(r["id"]),
@@ -6344,14 +6348,10 @@ async def precedent_request_halachot(case_law_id: str):
return {"queued": True, "case_law_id": case_law_id, "kind": "halacha", "wakeup": wakeup}
@app.get("/api/precedent-library/queue/pending")
async def precedent_queue_pending(kind: str = "metadata", limit: int = 20):
"""Read-only view of the queue. The MCP worker reads this too, but the
UI calls it to show 'X ממתינות לעיבוד מקומי' badges."""
if kind not in {"metadata", "halacha"}:
raise HTTPException(400, "kind חייב להיות metadata או halacha")
items = await db.list_pending_extraction_requests(kind=kind, limit=limit)
return {"items": items, "count": len(items)}
# PRE-2 (INV-IA1/dead-surface): removed GET /api/precedent-library/queue/pending
# it had zero frontend consumers (its docstring's "the UI calls it" claim was
# stale; only /api/digests/queue/pending is consumed). The MCP worker reads the
# queue via db.list_pending_extraction_requests directly, not over HTTP.
# ── Digests radar (X12) — secondary discovery layer ─────────────────