From b58c5e7c4aac991c05bf029c07461f5b16a8e04c Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:21:42 -0500 Subject: [PATCH 1/3] feat: integrate ChittyDisputes with Notion Task Triager and ChittyRouter TriageAgent Connect ChittyCommand's dispute system bidirectionally with Notion and add AI-powered dispute scoring via ChittyRouter's TriageAgent. - Add dispute-sync.ts coordinator with 3 public entry points: fireDisputeSideEffects, reconcileNotionDisputes, pushUnlinkedDisputesToNotion - Add notionClient() to integrations.ts for writing task pages to Notion - Add classifyDispute() to routerClient for TriageAgent scoring - Replace direct ledgerClient fire-and-forget with unified side effects (Notion + TriageAgent + Ledger in parallel via Promise.allSettled) - Add bridge route POST /api/bridge/disputes/sync-notion for manual sync - Add cron Phase 10: auto-create disputes from legal-type Notion tasks - Add migration 0012 with metadata indexes for loop-guard lookups - 3-layer loop prevention: metadata flags on both sides + Notion upsert dedup Co-Authored-By: Claude Opus 4.6 --- migrations/0012_dispute_sync_indexes.sql | 19 ++ src/lib/cron.ts | 13 + src/lib/dispute-sync.ts | 322 +++++++++++++++++++++++ src/lib/integrations.ts | 117 ++++++++ src/routes/bridge/disputes.ts | 58 ++++ src/routes/bridge/index.ts | 2 + src/routes/disputes.ts | 33 +-- 7 files changed, 549 insertions(+), 15 deletions(-) create mode 100644 migrations/0012_dispute_sync_indexes.sql create mode 100644 src/lib/dispute-sync.ts create mode 100644 src/routes/bridge/disputes.ts diff --git a/migrations/0012_dispute_sync_indexes.sql b/migrations/0012_dispute_sync_indexes.sql new file mode 100644 index 0000000..050f6de --- /dev/null +++ b/migrations/0012_dispute_sync_indexes.sql @@ -0,0 +1,19 @@ +-- 0012_dispute_sync_indexes.sql +-- Indexes for dispute ↔ Notion ↔ TriageAgent sync lookups. + +-- Fast lookup: find disputes by notion_task_id (loop guard in reconcileNotionDisputes) +CREATE INDEX IF NOT EXISTS idx_cc_disputes_notion_task_id + ON cc_disputes ((metadata->>'notion_task_id')) + WHERE metadata->>'notion_task_id' IS NOT NULL; + +-- Fast lookup: find tasks by dispute_id (loop guard) +CREATE INDEX IF NOT EXISTS idx_cc_tasks_dispute_id + ON cc_tasks ((metadata->>'dispute_id')) + WHERE metadata->>'dispute_id' IS NOT NULL; + +-- Partial index for the reconciliation query (legal tasks not yet linked to disputes) +CREATE INDEX IF NOT EXISTS idx_cc_tasks_legal_unlinked + ON cc_tasks (priority ASC, created_at ASC) + WHERE task_type = 'legal' + AND backend_status NOT IN ('done', 'verified') + AND (metadata->>'dispute_id') IS NULL; diff --git a/src/lib/cron.ts b/src/lib/cron.ts index b4094bb..f515c78 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -6,6 +6,7 @@ import { matchTransactions } from './matcher'; import { generateProjections } from './projections'; import { discoverRevenueSources } from './revenue'; import { generatePaymentPlan, savePaymentPlan } from './payment-planner'; +import { reconcileNotionDisputes } from './dispute-sync'; /** * Cron sync orchestrator. @@ -120,6 +121,18 @@ export async function runCronSync( } catch (err) { console.error('[cron:notion_tasks] failed:', err); } + + // Phase 10: Dispute-Notion reconciliation + // Auto-creates cc_disputes from legal tasks not yet linked. + try { + const disputesSynced = await reconcileNotionDisputes(env, sql); + if (disputesSynced > 0) { + recordsSynced += disputesSynced; + console.log(`[cron:dispute_reconcile] created ${disputesSynced} disputes from Notion legal tasks`); + } + } catch (err) { + console.error('[cron:dispute_reconcile] failed:', err); + } } if (source === 'utility_scrape') { diff --git a/src/lib/dispute-sync.ts b/src/lib/dispute-sync.ts new file mode 100644 index 0000000..96b75dc --- /dev/null +++ b/src/lib/dispute-sync.ts @@ -0,0 +1,322 @@ +/** + * dispute-sync.ts + * + * Coordinator for ChittyDisputes ↔ Notion ↔ TriageAgent sync. + * + * Three public entry points: + * - fireDisputeSideEffects (called by POST /api/disputes) + * - reconcileNotionDisputes (called by daily cron Phase 10) + * - pushUnlinkedDisputesToNotion (called by bridge route) + * + * Loop prevention: metadata->>'notion_task_id' on disputes, + * metadata->>'dispute_id' on tasks. + */ + +import type { NeonQueryFunction } from '@neondatabase/serverless'; +import type { Env } from '../index'; +import { notionClient, routerClient, ledgerClient } from './integrations'; + +// ── Types ───────────────────────────────────────────────────── + +interface DisputeCore { + id: string; + title: string; + counterparty: string; + dispute_type: string; + amount_at_stake?: number | null; + description?: string | null; + priority: number; + metadata?: Record | null; +} + +// ── Public API ──────────────────────────────────────────────── + +/** + * Called immediately after INSERT into cc_disputes. + * Runs side effects in parallel: Notion task, TriageAgent, Ledger case. + * None blocks HTTP response — caller wraps in waitUntil(). + */ +export async function fireDisputeSideEffects( + dispute: DisputeCore, + env: Env, + sql: NeonQueryFunction, +): Promise { + const meta = (dispute.metadata || {}) as Record; + const tasks: Promise[] = []; + + // Notion task — skip if already linked (loop guard) + if (!meta.notion_task_id) { + tasks.push(linkDisputeToNotion(dispute.id, dispute, env, sql)); + } + + // TriageAgent scoring — always runs + tasks.push(scoreDisputeWithTriage(dispute.id, dispute, env, sql)); + + // ChittyLedger case — skip if already linked + if (!meta.ledger_case_id) { + tasks.push(linkDisputeToLedger(dispute.id, dispute, env, sql)); + } + + await Promise.allSettled(tasks); +} + +/** + * Cron Phase 10: find cc_tasks with task_type='legal' not yet linked + * to a dispute and auto-create cc_disputes rows. + */ +export async function reconcileNotionDisputes( + env: Env, + sql: NeonQueryFunction, +): Promise { + const legalTasks = await sql` + SELECT id, title, description, priority, due_date, notion_page_id, metadata + FROM cc_tasks + WHERE task_type = 'legal' + AND backend_status NOT IN ('done', 'verified') + AND (metadata->>'dispute_id') IS NULL + ORDER BY priority ASC, created_at ASC + LIMIT 50 + `; + + let created = 0; + + for (const task of legalTasks) { + const taskId = task.id as string; + const notionPageId = task.notion_page_id as string | null; + + // Skip tasks without a Notion origin — prevents loop where we'd create a + // Notion page that syncs back as a new task on the next cron run + if (!notionPageId) continue; + + try { + // Check if a dispute already exists with this notion_task_id + if (notionPageId) { + const [existing] = await sql` + SELECT id FROM cc_disputes + WHERE metadata->>'notion_task_id' = ${notionPageId} + LIMIT 1 + `; + if (existing) { + // Link task back and skip + await sql` + UPDATE cc_tasks + SET metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ dispute_id: existing.id })}::jsonb, + updated_at = NOW() + WHERE id = ${taskId} + `; + continue; + } + } + + // Create dispute with notion_task_id pre-set (suppresses Notion write in side effects) + const disputeMeta: Record = { + notion_task_id: notionPageId, + source: 'notion_task', + source_task_id: taskId, + }; + + const [dispute] = await sql` + INSERT INTO cc_disputes (title, counterparty, dispute_type, priority, description, metadata) + VALUES ( + ${task.title as string}, + 'Unknown', + 'legal', + ${(task.priority as number) || 5}, + ${(task.description as string | null) || null}, + ${JSON.stringify(disputeMeta)}::jsonb + ) + RETURNING id, title, counterparty, dispute_type, priority, description, metadata + `; + + // Link task back (loop guard for future cron runs) + await sql` + UPDATE cc_tasks + SET metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ dispute_id: dispute.id })}::jsonb, + updated_at = NOW() + WHERE id = ${taskId} + `; + + await sql` + INSERT INTO cc_actions_log (action_type, target_type, target_id, description, status, metadata) + VALUES ( + 'dispute_auto_created', 'dispute', ${dispute.id as string}, + ${'Auto-created from Notion legal task: ' + (task.title as string)}, + 'completed', + ${JSON.stringify({ source_task_id: taskId, notion_page_id: notionPageId })}::jsonb + ) + `; + + // TriageAgent + Ledger (Notion write suppressed by notion_task_id in metadata) + await fireDisputeSideEffects( + { + id: dispute.id as string, + title: dispute.title as string, + counterparty: dispute.counterparty as string, + dispute_type: dispute.dispute_type as string, + priority: dispute.priority as number, + description: dispute.description as string | null, + metadata: dispute.metadata as Record, + }, + env, + sql, + ); + + created++; + } catch (err) { + console.error(`[dispute-sync:reconcile] Failed for task ${taskId}:`, err); + } + } + + return created; +} + +/** + * Bridge trigger: push disputes without a Notion task link to Notion. + * Idempotent — safe to call repeatedly. + */ +export async function pushUnlinkedDisputesToNotion( + env: Env, + sql: NeonQueryFunction, +): Promise { + const unlinked = await sql` + SELECT id, title, counterparty, dispute_type, priority, description, metadata + FROM cc_disputes + WHERE (metadata->>'notion_task_id') IS NULL + AND status NOT IN ('resolved', 'dismissed') + ORDER BY created_at ASC + LIMIT 50 + `; + + let pushed = 0; + + for (const dispute of unlinked) { + try { + await linkDisputeToNotion(dispute.id as string, dispute as unknown as DisputeCore, env, sql); + pushed++; + } catch (err) { + console.error(`[dispute-sync:push] Failed for dispute ${dispute.id}:`, err); + } + } + + return pushed; +} + +// ── Internal helpers ────────────────────────────────────────── + +async function linkDisputeToNotion( + disputeId: string, + dispute: Pick, + env: Env, + sql: NeonQueryFunction, +): Promise { + try { + const notion = notionClient(env); + if (!notion) { + console.warn('[dispute-sync:notion] notionClient unavailable'); + return; + } + + const page = await notion.createTask({ + title: dispute.title, + description: dispute.description || undefined, + task_type: 'legal', + priority: dispute.priority, + source: 'chittycommand_dispute', + tags: [dispute.dispute_type], + }); + + if (!page?.page_id) { + console.warn(`[dispute-sync:notion] createTask returned null for dispute ${disputeId}`); + return; + } + + await sql` + UPDATE cc_disputes + SET metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ notion_task_id: page.page_id, notion_url: page.url })}::jsonb, + updated_at = NOW() + WHERE id = ${disputeId} + `; + + console.log(`[dispute-sync:notion] Linked dispute ${disputeId} → Notion page ${page.page_id}`); + } catch (err) { + console.error(`[dispute-sync:notion] Failed for dispute ${disputeId}:`, err); + } +} + +async function scoreDisputeWithTriage( + disputeId: string, + dispute: Pick, + env: Env, + sql: NeonQueryFunction, +): Promise { + try { + const router = routerClient(env); + if (!router) { + console.warn('[dispute-sync:triage] routerClient unavailable'); + return; + } + + const result = await router.classifyDispute({ + entity_id: disputeId, + entity_type: 'dispute', + title: dispute.title, + dispute_type: dispute.dispute_type, + amount: dispute.amount_at_stake ? Number(dispute.amount_at_stake) : undefined, + description: dispute.description || undefined, + }); + + if (!result) { + console.warn(`[dispute-sync:triage] TriageAgent returned null for dispute ${disputeId}`); + return; + } + + await sql` + UPDATE cc_disputes + SET priority = ${result.priority}, + metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ + triage_severity: result.severity, + triage_priority: result.priority, + triage_labels: result.labels, + triage_reasoning: result.reasoning || null, + triage_at: new Date().toISOString(), + })}::jsonb, + updated_at = NOW() + WHERE id = ${disputeId} + `; + + console.log(`[dispute-sync:triage] Scored dispute ${disputeId}: severity=${result.severity} priority=${result.priority}`); + } catch (err) { + console.error(`[dispute-sync:triage] Failed for dispute ${disputeId}:`, err); + } +} + +async function linkDisputeToLedger( + disputeId: string, + dispute: Pick, + env: Env, + sql: NeonQueryFunction, +): Promise { + try { + const ledger = ledgerClient(env); + if (!ledger) return; + + const caseResult = await ledger.createCase({ + caseNumber: `CC-DISPUTE-${disputeId.slice(0, 8)}`, + title: dispute.title, + caseType: 'CIVIL', + description: dispute.description || undefined, + }); + + if (caseResult?.id) { + await sql` + UPDATE cc_disputes + SET metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ ledger_case_id: caseResult.id })}::jsonb, + updated_at = NOW() + WHERE id = ${disputeId} + `; + console.log(`[dispute-sync:ledger] Linked dispute ${disputeId} → case ${caseResult.id}`); + } + } catch (err) { + console.error(`[dispute-sync:ledger] Failed for dispute ${disputeId}:`, err); + } +} diff --git a/src/lib/integrations.ts b/src/lib/integrations.ts index 8b4c161..94d346c 100644 --- a/src/lib/integrations.ts +++ b/src/lib/integrations.ts @@ -611,5 +611,122 @@ export function routerClient(env: Env) { getEmailStatus: () => get>('/email/status'), + + /** Classify a dispute via ChittyRouter TriageAgent */ + classifyDispute: (payload: { + entity_id: string; + entity_type: 'dispute'; + title: string; + dispute_type: string; + amount?: number; + description?: string; + }) => + post<{ + severity: number; + priority: number; + labels: string[]; + reasoning?: string; + }>('/agents/triage/classify', payload), + }; +} + +// ── Notion (write path) ─────────────────────────────────────── +// Reading Notion is handled by syncNotionTasks() in cron.ts. +// This client covers the write path: creating task pages from disputes. + +export interface NotionTaskPayload { + title: string; + description?: string; + task_type: string; + priority: number; + due_date?: string; + source: string; + tags?: string[]; +} + +export interface NotionPageResult { + page_id: string; + url: string; +} + +export function notionClient(env: Env) { + if (!env.COMMAND_KV) return null; + + async function resolveCredentials(): Promise<{ token: string; dbId: string } | null> { + const [token, dbId] = await Promise.all([ + env.COMMAND_KV.get('notion:task_agent_token'), + env.COMMAND_KV.get('notion:dispute_database_id'), + ]); + if (!token || !dbId) return null; + return { token, dbId }; + } + + return { + createTask: async (payload: NotionTaskPayload): Promise => { + try { + const creds = await resolveCredentials(); + if (!creds) { + console.warn('[notion] Missing notion:task_agent_token or notion:dispute_database_id in KV'); + return null; + } + + const properties: Record = { + 'Title': { + title: [{ type: 'text', text: { content: payload.title.slice(0, 2000) } }], + }, + 'Type': { + select: { name: payload.task_type }, + }, + 'Priority': { + number: payload.priority, + }, + 'Source': { + select: { name: payload.source }, + }, + }; + + if (payload.description) { + properties['Description'] = { + rich_text: [{ type: 'text', text: { content: payload.description.slice(0, 2000) } }], + }; + } + + if (payload.due_date) { + properties['Due Date'] = { date: { start: payload.due_date } }; + } + + if (payload.tags?.length) { + properties['Tags'] = { + multi_select: payload.tags.map((t) => ({ name: t })), + }; + } + + const res = await fetch('https://api.notion.com/v1/pages', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${creds.token}`, + 'Notion-Version': '2022-06-28', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + parent: { database_id: creds.dbId }, + properties, + }), + signal: AbortSignal.timeout(10000), + }); + + if (!res.ok) { + const errBody = await res.text().catch(() => ''); + console.error(`[notion] createTask failed: ${res.status} ${errBody}`); + return null; + } + + const page = await res.json() as { id: string; url: string }; + return { page_id: page.id, url: page.url }; + } catch (err) { + console.error('[notion] createTask error:', err); + return null; + } + }, }; } diff --git a/src/routes/bridge/disputes.ts b/src/routes/bridge/disputes.ts new file mode 100644 index 0000000..d6609e2 --- /dev/null +++ b/src/routes/bridge/disputes.ts @@ -0,0 +1,58 @@ +/** + * bridge/disputes.ts + * + * Manual bidirectional sync trigger for disputes ↔ Notion. + * Mounted at /api/bridge/disputes via bridgeAuthMiddleware. + */ + +import { Hono } from 'hono'; +import { z } from 'zod'; +import type { Env } from '../../index'; +import type { AuthVariables } from '../../middleware/auth'; +import { getDb } from '../../lib/db'; +import { pushUnlinkedDisputesToNotion, reconcileNotionDisputes } from '../../lib/dispute-sync'; + +export const disputesBridgeRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); + +const syncDirectionSchema = z.object({ + direction: z.enum(['to_notion', 'from_notion', 'both']).optional().default('both'), +}); + +/** + * POST /api/bridge/disputes/sync-notion + * + * to_notion — push cc_disputes with no notion_task_id to Notion + * from_notion — scan cc_tasks(legal) and auto-create cc_disputes + * both — run both in sequence (default) + */ +disputesBridgeRoutes.post('/sync-notion', async (c) => { + const parsed = syncDirectionSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) { + return c.json({ error: 'Invalid request', details: parsed.error.flatten() }, 400); + } + + const { direction } = parsed.data; + const sql = getDb(c.env); + const start = Date.now(); + + let pushed = 0; + let reconciled = 0; + + if (direction === 'to_notion' || direction === 'both') { + try { + pushed = await pushUnlinkedDisputesToNotion(c.env, sql); + } catch (err) { + console.error('[bridge:disputes:sync-notion] push failed:', err); + } + } + + if (direction === 'from_notion' || direction === 'both') { + try { + reconciled = await reconcileNotionDisputes(c.env, sql); + } catch (err) { + console.error('[bridge:disputes:sync-notion] reconcile failed:', err); + } + } + + return c.json({ pushed, reconciled, direction, duration_ms: Date.now() - start }); +}); diff --git a/src/routes/bridge/index.ts b/src/routes/bridge/index.ts index af16361..7ab8c49 100644 --- a/src/routes/bridge/index.ts +++ b/src/routes/bridge/index.ts @@ -9,6 +9,7 @@ import { mercuryRoutes } from './mercury'; import { booksRoutes } from './books'; import { assetsBridgeRoutes } from './assets'; import { scrapeRoutes } from './scrape'; +import { disputesBridgeRoutes } from './disputes'; import { statusRoutes } from './status'; export const bridgeRoutes = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); @@ -21,4 +22,5 @@ bridgeRoutes.route('/mercury', mercuryRoutes); bridgeRoutes.route('/books', booksRoutes); bridgeRoutes.route('/assets', assetsBridgeRoutes); bridgeRoutes.route('/scrape', scrapeRoutes); +bridgeRoutes.route('/disputes', disputesBridgeRoutes); bridgeRoutes.route('/', statusRoutes); diff --git a/src/routes/disputes.ts b/src/routes/disputes.ts index e3db00f..0ac8d65 100644 --- a/src/routes/disputes.ts +++ b/src/routes/disputes.ts @@ -1,8 +1,8 @@ import { Hono } from 'hono'; import type { Env } from '../index'; import { getDb } from '../lib/db'; -import { ledgerClient } from '../lib/integrations'; import { createDisputeSchema, updateDisputeSchema, createCorrespondenceSchema, disputeQuerySchema } from '../lib/validators'; +import { fireDisputeSideEffects } from '../lib/dispute-sync'; export const disputeRoutes = new Hono<{ Bindings: Env }>(); const TERMINAL_STATUSES = new Set(['resolved', 'dismissed']); @@ -80,20 +80,23 @@ disputeRoutes.post('/', async (c) => { VALUES (${body.title}, ${body.counterparty}, ${body.dispute_type}, ${body.amount_claimed || null}, ${body.amount_at_stake || null}, ${stage}, ${status}, ${body.priority || 5}, ${body.description || null}, ${body.next_action || null}, ${body.next_action_date || null}, ${body.resolution_target || null}, ${JSON.stringify(body.metadata || {})}) RETURNING * `; - // Fire-and-forget: push to ChittyLedger as a case - const ledger = ledgerClient(c.env); - if (ledger) { - ledger.createCase({ - caseNumber: `CC-DISPUTE-${(dispute.id as string).slice(0, 8)}`, - title: body.title, - caseType: 'CIVIL', - description: body.description || undefined, - }).then((caseResult) => { - if (caseResult?.id) { - sql`UPDATE cc_disputes SET metadata = COALESCE(metadata, '{}'::jsonb) || ${JSON.stringify({ ledger_case_id: caseResult.id })}::jsonb WHERE id = ${dispute.id}`.catch(() => {}); - } - }).catch(() => {}); - } + // Fire-and-forget: Notion task, TriageAgent scoring, and ChittyLedger case + c.executionCtx.waitUntil( + fireDisputeSideEffects( + { + id: dispute.id as string, + title: body.title, + counterparty: body.counterparty, + dispute_type: body.dispute_type, + amount_at_stake: body.amount_at_stake ?? null, + description: body.description ?? null, + priority: (body.priority ?? 5) as number, + metadata: (dispute.metadata as Record) ?? {}, + }, + c.env, + sql, + ) + ); return c.json(dispute, 201); }); From 25b808543feaf721dbd5bb37612961e8b93d142a Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:36:45 -0500 Subject: [PATCH 2/3] fix: Priority property name, vanity domain, and cron Priority fallback - Fix notionClient to write 'Priority 1' matching actual Notion DB schema - Add disputes.chitty.cc as custom domain alias on the Worker - Add Priority 1 fallback in cron Phase 9 Notion task sync Co-Authored-By: Claude Opus 4.6 --- src/lib/cron.ts | 2 +- src/lib/integrations.ts | 2 +- wrangler.toml | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/cron.ts b/src/lib/cron.ts index f515c78..d8ba136 100644 --- a/src/lib/cron.ts +++ b/src/lib/cron.ts @@ -650,7 +650,7 @@ export async function syncNotionTasks(env: Env, sql: NeonQueryFunction Date: Mon, 9 Mar 2026 13:44:14 -0500 Subject: [PATCH 3/3] docs: add Notion Task Triager integration instructions Instructions for configuring the Notion Task Triager agent to classify legal/dispute emails into the Business Task Tracker with properties that auto-sync into ChittyCommand disputes. Co-Authored-By: Claude Opus 4.6 --- docs/notion-task-triager-instructions.md | 78 ++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 docs/notion-task-triager-instructions.md diff --git a/docs/notion-task-triager-instructions.md b/docs/notion-task-triager-instructions.md new file mode 100644 index 0000000..91d4033 --- /dev/null +++ b/docs/notion-task-triager-instructions.md @@ -0,0 +1,78 @@ +# Notion Task Triager — ChittyCommand Dispute Integration + +Add the following to the Task Triager agent's instructions in Notion Settings > Connections > Task Triager > Edit instructions. + +--- + +## Dispute & Legal Task Classification + +When you receive an email or message related to any of the following topics, classify it as a **legal task** so it automatically syncs into ChittyCommand's dispute tracker: + +- Disputes with vendors, landlords, tenants, contractors, or counterparties +- Insurance claims or denials +- Court filings, summons, motions, or docket updates +- Property damage, water damage, or maintenance disputes +- Legal deadlines, statute of limitations, or response due dates +- Demand letters, cease & desist, or settlement offers +- Payment disputes, chargebacks, or billing errors +- Government notices (IRS, county, city violations) + +### Required Properties for Legal Tasks + +When creating a task page in the **Business Task Tracker** database for dispute/legal items: + +| Property | Value | Notes | +|----------|-------|-------| +| **Title** | Clear, descriptive title | e.g. "Water damage claim — 123 Main St — Allstate denial" | +| **Type** | `Legal` | MUST be "Legal" for ChittyCommand to pick it up as a dispute | +| **Source** | `Email` | Use "Email" for email-ingested items, "Mention" for @-mentions | +| **Priority 1** | 1-10 (number) | 1 = most urgent. Use 1-3 for court deadlines, 4-6 for active disputes, 7-10 for monitoring | +| **Tags** | One or more from the list below | Helps categorize the dispute type | +| **Description** | Summarize the key facts | Include: who, what, amounts, dates, and any deadlines mentioned | +| **Due Date** | Set if there's an explicit deadline | Court dates, response deadlines, filing windows | + +### Tag Guidelines + +Apply one or more of these tags based on the content: + +- `Dispute` — General disputes with any counterparty +- `Insurance-claim` — Insurance claims, denials, appeals +- `Court-filing` — Court documents, motions, hearings, docket activity +- `Property-issue` — Property damage, maintenance, HOA issues +- `Vendor-dispute` — Contractor, vendor, or service provider disputes +- `Legal-deadline` — Time-sensitive legal obligations +- `Payment` — Payment disputes, chargebacks, billing errors +- `Tax` — IRS notices, property tax disputes, assessments +- `Utility` — Utility billing disputes (ComEd, Peoples Gas, etc.) + +### Priority Guidance + +| Priority | Use When | +|----------|----------| +| 1-2 | Court deadline within 7 days, active hearing, imminent statute expiry | +| 3-4 | Response needed within 30 days, active negotiations, pending insurance decision | +| 5-6 | Monitoring active disputes, follow-up needed, no immediate deadline | +| 7-8 | Informational notices, early-stage inquiries, low-stakes items | +| 9-10 | Archive-worthy, resolved but tracking, general awareness | + +### What Happens After Creation + +Once you create a legal task in the Business Task Tracker: + +1. **ChittyCommand's daily cron** (6 AM CT) syncs new legal tasks into `cc_disputes` +2. **TriageAgent** automatically scores the dispute for severity and priority +3. **ChittyLedger** creates a case record for chain-of-custody tracking +4. The dispute appears in the ChittyCommand dashboard for action tracking + +You do NOT need to create anything in ChittyCommand directly — the sync is automatic. + +### Examples + +**Email**: "Allstate denied claim #CLM-2024-8847 for water damage at 4521 S Drexel..." +→ Type: `Legal` | Priority 1: `3` | Tags: `Insurance-claim`, `Property-issue` | Due Date: appeal deadline if mentioned + +**Email**: "Cook County Circuit Court — Notice of hearing, Case 2024D007847, March 15..." +→ Type: `Legal` | Priority 1: `1` | Tags: `Court-filing`, `Legal-deadline` | Due Date: `2026-03-15` + +**Email**: "Mr. Cooper mortgage — escrow shortage notice, payment increase effective..." +→ Type: `Legal` | Priority 1: `5` | Tags: `Payment`, `Property-issue` | Due Date: effective date