From a79a482aa40bb61c300c1f7bdd227410f875b3c7 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:30:28 +0000 Subject: [PATCH 1/2] fix(legal): matter data is config, never a literal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live case number was compiled into the product in seven places. That is the "no defaulting to the active case" prohibition in legal-operating-defaults §8, and one instance is a privilege problem rather than a tidiness one. src/routes/bridge/scrape.ts caseNumber || '' -- the route silently substituted one operator's matter whenever the caller omitted it. caseNumber is now REQUIRED in courtDocketScrapeSchema; an absent matter is a 400, not a guess. src/lib/cron.ts the scheduled docket sync scraped one hardcoded matter and wrote it into cc_legal_deadlines. Now iterates KV `legal:tracked_cases`. Unset config means the cron does nothing and logs that it did nothing; it never picks a matter. src/lib/contextual-ingest.ts LEGAL_CASE_PATTERN keyed privilege/sensitivity classification to ONE case number. Every other matter's work product was therefore classified non-sensitive -- a §9 privilege gap, silent by construction. Now matches court-case-number SHAPES (a superset of the old literal) plus refs configured in KV `legal:case_refs`. ui/.../LitigationAssistant the case field was prefilled with a live matter, so every user started inside someone else's case. ui/.../Legal.tsx placeholder used a real case number; now a format example. src/agents/tools/storage.ts tool descriptions used a real party slug as the "e.g."; these ship in the tool schema. BEHAVIOUR CHANGE, called out rather than buried: non-docket refs (unit numbers, party names) have no derivable shape and move to KV `legal:case_refs`. Until that key is seeded this is NARROWER than the old hardcoded behaviour, so the classifier warns when the key is unset instead of failing silently. NOT FIXED: migrations/0004_seed_data.sql:138 carries a real matter caption. Editing an applied migration is its own risk; flagged for a separate change. VERIFICATION GAP, stated plainly: typecheck could NOT run. `npm ci` fails on main with `No matching version found for hono-agents@^0.19.0` -- a pre-existing broken dependency that PR #132 is already addressing. All seven changed files pass esbuild parse, which catches syntax and not types. This needs a typecheck once #132 lands; do not read parse-OK as compile-OK. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0172kkaE1YjR9UQMnGNa2bij --- src/agents/tools/storage.ts | 4 +- src/lib/contextual-ingest.ts | 51 +++++++++++++++++-- src/lib/cron.ts | 75 ++++++++++++++++++++-------- src/lib/validators.ts | 6 ++- src/routes/bridge/scrape.ts | 3 +- ui/src/pages/Legal.tsx | 2 +- ui/src/pages/LitigationAssistant.tsx | 4 +- 7 files changed, 114 insertions(+), 31 deletions(-) diff --git a/src/agents/tools/storage.ts b/src/agents/tools/storage.ts index 1c988fd..1e1e79d 100644 --- a/src/agents/tools/storage.ts +++ b/src/agents/tools/storage.ts @@ -43,7 +43,7 @@ export function createStorageTools() { description: 'Search documents in ChittyStorage by entity, filename, or content hash. Returns matching documents with metadata.', inputSchema: z.object({ query: z.string().describe('Search query — entity slug, filename, or SHA-256 hash'), - entity: z.string().optional().describe('Filter by entity slug (e.g., "arias-v-bianchi")'), + entity: z.string().optional().describe('Filter by entity slug (e.g., "smith-v-jones")'), limit: z.number().min(1).max(50).optional().describe('Max results (default 20)'), }), execute: async ({ query, entity, limit }) => { @@ -68,7 +68,7 @@ export function createStorageTools() { list_entity_documents: tool({ description: 'List all documents stored for a specific entity.', inputSchema: z.object({ - entity: z.string().describe('Entity slug (e.g., "arias-v-bianchi", "it-can-be-llc")'), + entity: z.string().describe('Entity slug (e.g., "smith-v-jones", "example-holdings-llc")'), }), execute: async ({ entity }) => { return callStorageMcp('storage_entity', { entity }); diff --git a/src/lib/contextual-ingest.ts b/src/lib/contextual-ingest.ts index c466ce1..030c2e3 100644 --- a/src/lib/contextual-ingest.ts +++ b/src/lib/contextual-ingest.ts @@ -10,7 +10,7 @@ * ChittyLedger-Messaging / delicate-moon — read-only). Candidates are * messages carrying an `amount` entity plus a payee-like entity * (person/org). `date` entities give a due date; `legal_document` and - * case-ref entities (#287 / #239 / 2024D007847) drive sensitivity. + * case-ref entities (court case numbers and configured matter refs) drive sensitivity. * 2. Route/classify via chittyrouter. We attempt its intelligentRoute surface * (POST /process); the deployed edge currently does not expose it * (404) and /agents/triage/classify returns a deterministic stub, so we @@ -47,9 +47,47 @@ import { contextualTasksClient } from './integrations'; const ROUTER_MODEL = '@cf/meta/llama-3.1-8b-instruct-fast'; // Case refs / patterns that force legalink sensitivity + privileged intent. -// @canon: chittyview-projection-spine — divorce 2024D007847 work product is -// privileged even though it is business-relevant. -const LEGAL_CASE_PATTERN = /2024D007847|#?\s?287\b|#?\s?239\b|arias\s+v\.?\s+bianchi/i; +// @canon: chittyview-projection-spine — litigation work product is privileged +// even when it is business-relevant. +// +// This matches court-case-number SHAPES, not one matter. Keying privilege +// classification to a single hardcoded case meant every OTHER matter's work +// product was silently classified non-sensitive — a §9 privilege risk, not a +// tidiness problem. The shapes below are a superset of what the literal matched. +// +// Non-docket refs (unit numbers, party names, internal slugs) cannot be derived +// from a shape and are read from KV `legal:case_refs` instead. See +// matchesConfiguredCaseRef(). +const LEGAL_CASE_PATTERN = + /\b(\d{4}\s?D\s?\d{6}|\d{2}\s?M\d\s?\d{6}|\d{4}-\d{2}-\d{5}|\d{2}-?[A-Z]{1,3}-?\d{4,6})\b/i; + +/** + * Additional matter references that have no derivable shape — unit numbers, + * party names, internal slugs. Stored as a JSON array of strings in KV + * `legal:case_refs`. + * + * These are matter data and never belong in source. If the key is unset the + * classifier falls back to shapes and keywords alone, which is NARROWER than + * the old hardcoded behaviour for non-docket refs — so it warns rather than + * failing silently. + */ +let configuredRefsCache: string[] | null = null; +async function matchesConfiguredCaseRef(env: Env, text: string): Promise { + if (configuredRefsCache === null) { + try { + const raw = await env.COMMAND_KV.get('legal:case_refs'); + configuredRefsCache = raw ? (JSON.parse(raw) as string[]).filter((r) => typeof r === 'string' && r) : []; + if (configuredRefsCache.length === 0) { + console.warn('[contextual-ingest] KV legal:case_refs unset — non-docket matter refs will not force legal sensitivity'); + } + } catch (err) { + console.error('[contextual-ingest] legal:case_refs is not valid JSON:', err); + configuredRefsCache = []; + } + } + const lower = text.toLowerCase(); + return configuredRefsCache.some((ref) => lower.includes(ref.toLowerCase())); +} const LEGAL_KEYWORD_PATTERN = /\b(lawsuit|subpoena|court|hearing|motion|deposition|notice of motion|debt[-\s]?collection|collection agency)\b/i; export interface ContextualCandidate { @@ -217,7 +255,10 @@ export async function classifyCandidate( ): Promise { const text = `${cand.body_text}`.slice(0, 1500); const legalSignal = - cand.has_legal_doc || LEGAL_CASE_PATTERN.test(text) || LEGAL_KEYWORD_PATTERN.test(text); + cand.has_legal_doc || + LEGAL_CASE_PATTERN.test(text) || + (await matchesConfiguredCaseRef(env, text)) || + LEGAL_KEYWORD_PATTERN.test(text); // 1) chittyrouter intelligentRoute (POST /process). Returns ai.analysis with // category/priority/urgency_score when available. diff --git a/src/lib/cron.ts b/src/lib/cron.ts index dfafe44..7b9ebb1 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -210,10 +210,16 @@ export async function runCronSync( try { // Enqueue via dispatcher for retry + fan-out const chittyId = await env.COMMAND_KV.get('default:chitty_id') || undefined; - await enqueueJob(sql, 'court_docket', { case_number: '2024D007847' }, { - chittyId, - cronSource: 'court_docket', - }, env); + const trackedCases = await getTrackedCases(env); + if (trackedCases.length === 0) { + console.warn('[cron:court_docket] no cases configured in KV legal:tracked_cases — nothing to sync'); + } + for (const caseNumber of trackedCases) { + await enqueueJob(sql, 'court_docket', { case_number: caseNumber }, { + chittyId, + cronSource: 'court_docket', + }, env); + } const queueResult = await processQueue(sql, env, ctx); recordsSynced += queueResult.succeeded; console.log(`[cron:court_docket] dispatcher: ${queueResult.succeeded} succeeded, ${queueResult.failed} failed`); @@ -520,6 +526,28 @@ export async function syncMercury(env: Env, sql: NeonQueryFunction return recordsSynced; } +/** + * Matters tracked by the docket cron, read from KV `legal:tracked_cases` + * (a JSON array of case numbers). + * + * This is config, never a literal. A hardcoded case number made every scheduled + * run operate on one operator's live matter and made the job unusable for any + * other -- the "no defaulting to the active case" prohibition in + * legal-operating-defaults §8. Unset config means the cron does nothing and says + * so; it never guesses a matter. + */ +async function getTrackedCases(env: Env): Promise { + try { + const raw = await env.COMMAND_KV.get('legal:tracked_cases'); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.filter((c): c is string => typeof c === 'string' && c.length > 0) : []; + } catch (err) { + console.error('[cron:court_docket] legal:tracked_cases is not valid JSON:', err); + return []; + } +} + /** * Scrape court docket via ChittyScrape and insert new deadlines. */ @@ -530,35 +558,42 @@ export async function syncCourtDocket(env: Env, sql: NeonQueryFunction { const parsed = courtDocketScrapeSchema.safeParse(await c.req.json()); if (!parsed.success) return c.json({ error: 'Validation failed', issues: parsed.error.issues }, 400); - const targetCase = parsed.data.caseNumber || '2024D007847'; + // No fallback. The schema now requires caseNumber; an absent matter is a 400. + const targetCase = parsed.data.caseNumber; const result = await scrape.scrapeCourtDocket(targetCase, token); diff --git a/ui/src/pages/Legal.tsx b/ui/src/pages/Legal.tsx index e084755..86cdbf9 100644 --- a/ui/src/pages/Legal.tsx +++ b/ui/src/pages/Legal.tsx @@ -101,7 +101,7 @@ export function Legal() { setForm(f => ({ ...f, case_ref: e.target.value }))} - placeholder="e.g. 2024D007847" + placeholder="e.g. 2019D001234" className="w-full px-3 py-2 rounded-lg bg-slate-50 border border-slate-200 text-card-text text-sm focus:outline-none focus:ring-2 focus:ring-chitty-500/50" /> diff --git a/ui/src/pages/LitigationAssistant.tsx b/ui/src/pages/LitigationAssistant.tsx index f6cb85b..c6c9c7d 100644 --- a/ui/src/pages/LitigationAssistant.tsx +++ b/ui/src/pages/LitigationAssistant.tsx @@ -29,7 +29,9 @@ export function LitigationAssistant() { const [step, setStep] = useState('idle'); const [rawNotes, setRawNotes] = useState(''); const [property, setProperty] = useState('550 W Surf St, Unit 504, Chicago, IL'); - const [caseNumber, setCaseNumber] = useState('2024D007847'); + // No prefill. A live matter number as the default made every user of this + // page start inside someone else's case (legal-operating-defaults §8). + const [caseNumber, setCaseNumber] = useState(''); const [focus, setFocus] = useState(FOCUS_OPTIONS[0]); const [recipient, setRecipient] = useState('Robert Alexander'); From e10d8728344e44a28092f673ecef8cb395053b0d Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:25:26 +0000 Subject: [PATCH 2/2] fix(legal): bound the case-ref cache, isolate per-case enqueue failures Two review findings on this PR, both real and both mine. contextual-ingest: the `configuredRefsCache === null` guard cached the first result for the isolate's lifetime. Workers reuse globals across requests, so seeding `legal:case_refs` afterwards would never take effect in that isolate, and a later config change would never propagate. Worse, a READ FAILURE was cached as "configured empty" -- permanently downgrading sensitivity classification for every subsequent request, which is the exact silent under-protection this PR exists to remove. Now: 60s TTL, and failures are never cached. On a failed read the previous good value is kept and retried next call; if there is no previous value the call returns false for that message only. cron: a throw from enqueueJob escaped to the outer catch, abandoning every later tracked case AND skipping processQueue for jobs that had already been queued. Enqueue errors are now caught per case; processQueue stays outside that catch so one bad case cannot strand the rest. Verified: typecheck exit 0; node suite 68 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0172kkaE1YjR9UQMnGNa2bij --- src/lib/contextual-ingest.ts | 24 +++++++++++++++++++----- src/lib/cron.ts | 14 ++++++++++---- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/src/lib/contextual-ingest.ts b/src/lib/contextual-ingest.ts index 030c2e3..00f2828 100644 --- a/src/lib/contextual-ingest.ts +++ b/src/lib/contextual-ingest.ts @@ -71,18 +71,32 @@ const LEGAL_CASE_PATTERN = * the old hardcoded behaviour for non-docket refs — so it warns rather than * failing silently. */ +// Bounded cache. Workers reuse globals across requests for the isolate's +// lifetime, so an unbounded cache would freeze the first result forever: +// seeding legal:case_refs afterwards would never take effect, and a later +// change would never propagate. A read FAILURE is never cached as "configured +// empty" -- doing so would silently and permanently downgrade sensitivity +// classification for every subsequent request in that isolate. +const CASE_REFS_TTL_MS = 60_000; let configuredRefsCache: string[] | null = null; +let configuredRefsFetchedAt = 0; + async function matchesConfiguredCaseRef(env: Env, text: string): Promise { - if (configuredRefsCache === null) { + const stale = Date.now() - configuredRefsFetchedAt > CASE_REFS_TTL_MS; + if (configuredRefsCache === null || stale) { try { const raw = await env.COMMAND_KV.get('legal:case_refs'); - configuredRefsCache = raw ? (JSON.parse(raw) as string[]).filter((r) => typeof r === 'string' && r) : []; - if (configuredRefsCache.length === 0) { + const parsed = raw ? (JSON.parse(raw) as string[]).filter((r) => typeof r === 'string' && r) : []; + configuredRefsCache = parsed; + configuredRefsFetchedAt = Date.now(); + if (parsed.length === 0) { console.warn('[contextual-ingest] KV legal:case_refs unset — non-docket matter refs will not force legal sensitivity'); } } catch (err) { - console.error('[contextual-ingest] legal:case_refs is not valid JSON:', err); - configuredRefsCache = []; + // Do NOT cache the failure. Keep any previously-good value and retry on + // the next call rather than locking in an empty configuration. + console.error('[contextual-ingest] legal:case_refs read/parse failed:', err); + if (configuredRefsCache === null) return false; } } const lower = text.toLowerCase(); diff --git a/src/lib/cron.ts b/src/lib/cron.ts index 7b9ebb1..9173ba7 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -214,11 +214,17 @@ export async function runCronSync( if (trackedCases.length === 0) { console.warn('[cron:court_docket] no cases configured in KV legal:tracked_cases — nothing to sync'); } + // Per-case isolation: one failing enqueue must not abandon the rest of + // the batch, nor skip processQueue for jobs that did enqueue. for (const caseNumber of trackedCases) { - await enqueueJob(sql, 'court_docket', { case_number: caseNumber }, { - chittyId, - cronSource: 'court_docket', - }, env); + try { + await enqueueJob(sql, 'court_docket', { case_number: caseNumber }, { + chittyId, + cronSource: 'court_docket', + }, env); + } catch (err) { + console.error(`[cron:court_docket] enqueue failed for ${caseNumber}:`, err); + } } const queueResult = await processQueue(sql, env, ctx); recordsSynced += queueResult.succeeded;