Skip to content

feat: integrate disputes with Notion Task Triager and TriageAgent#26

Open
chitcommit wants to merge 3 commits intomainfrom
feat/dispute-sync-notion-triage
Open

feat: integrate disputes with Notion Task Triager and TriageAgent#26
chitcommit wants to merge 3 commits intomainfrom
feat/dispute-sync-notion-triage

Conversation

@chitcommit
Copy link
Contributor

@chitcommit chitcommit commented Mar 9, 2026

Summary

  • Bidirectional Notion sync: Disputes auto-create tasks in the Business Task Tracker; legal-type Notion tasks auto-create disputes in ChittyCommand
  • TriageAgent scoring: New disputes are scored via ChittyRouter's /agents/triage/classify for AI-powered severity/priority
  • Unified side effects: Replaced direct ledgerClient fire-and-forget with dispute-sync.ts coordinator running Notion + TriageAgent + Ledger in parallel
  • Bridge route: POST /api/bridge/disputes/sync-notion for manual bidirectional reconciliation
  • 3-layer loop prevention: metadata flags on both dispute and task rows + Notion upsert dedup

New files

  • src/lib/dispute-sync.ts — Core sync coordinator
  • src/routes/bridge/disputes.ts — Manual sync bridge route
  • migrations/0012_dispute_sync_indexes.sql — Metadata indexes for loop guards

Modified files

  • src/lib/integrations.ts — Added notionClient() and routerClient.classifyDispute()
  • src/routes/disputes.ts — Wired fireDisputeSideEffects via waitUntil()
  • src/routes/bridge/index.ts — Registered disputes bridge
  • src/lib/cron.ts — Added Phase 10 reconciliation

Pre-deploy setup required

  1. Cloudflare redirect rule: disputes.chitty.cccommand.chitty.cc/api/disputes (307)
  2. KV: set notion:dispute_database_id to Business Task Tracker DB ID
  3. Notion: verify integration token has Insert Content permission on the Business Task Tracker
  4. Update Notion Task Triager instructions to route legal items to Business Task Tracker
  5. Run npm run db:migrate for index creation

Test plan

  • POST /api/disputes returns 201, logs show Notion task + TriageAgent + Ledger side effects
  • POST /api/bridge/disputes/sync-notion { direction: "both" } returns { pushed, reconciled }
  • Verify loop guard: dispute created from Notion task does NOT create a duplicate Notion page
  • Verify TriageAgent failure is non-blocking (dispute still created with default priority)
  • Verify notionClient returns null gracefully when KV keys missing

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Disputes now sync bidirectionally with Notion, a triage classifier, and the ledger; side effects run asynchronously.
    • Manual API endpoint to trigger dispute↔Notion synchronization with directional control.
    • Cron now reconciles disputes from Notion and supports improved priority fallback handling.
  • Chores
    • Added database indexes to speed dispute synchronization and reconciliation.
  • Documentation
    • New Notion Task Triager instructions and classification guidance.

…ter 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 <noreply@anthropic.com>
@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions
Copy link

github-actions bot commented Mar 9, 2026

  1. @coderabbitai review
  2. @copilot review
  3. @codex review
  4. @claude review
    Adversarial review request: evaluate security, policy bypass paths, regression risk, and merge-gating bypass attempts.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

Adds dispute synchronization: DB partial indexes, a new dispute-sync module (Notion, TriageAgent, Ledger), Notion/Triage integrations, cron Phase 10 to reconcile legal tasks into disputes, a bridge API to trigger syncs, and route changes to dispatch side effects asynchronously.

Changes

Cohort / File(s) Summary
Database Indexes
migrations/0012_dispute_sync_indexes.sql
Adds three partial indexes for fast lookup by metadata->>'notion_task_id', metadata->>'dispute_id', and unlinked legal tasks (priority, created_at) with appropriate NOT NULL filters.
Cron Integration
src/lib/cron.ts
Adds Phase 10 calling reconcileNotionDisputes(env, sql) and aggregates created dispute counts; extends Notion priority extraction to consider a secondary field.
Dispute Sync Core
src/lib/dispute-sync.ts
New module with fireDisputeSideEffects(), reconcileNotionDisputes(), and pushUnlinkedDisputesToNotion() to materialize/link disputes, invoke Notion/Triage/Ledger integrations, and guard loops via metadata.
Integration Clients
src/lib/integrations.ts
Adds routerClient.classifyDispute(), notionClient(env) plus NotionTaskPayload/NotionPageResult interfaces and credential resolution for creating Notion pages.
Bridge Routes
src/routes/bridge/disputes.ts, src/routes/bridge/index.ts
Adds bridge POST /api/bridge/disputes/sync-notion (direction: to_notion
Disputes Route Handler
src/routes/disputes.ts
Replaces direct ledger calls with executionCtx.waitUntil(fireDisputeSideEffects(...)), passing full dispute payload and decoupling side effects from response.
Config
wrangler.toml
Adds disputes.chitty.cc to deployment routes.
Docs
docs/notion-task-triager-instructions.md
Adds instructions and taxonomy for creating/classifying Notion legal tasks and outlines the automatic sync workflow.

Sequence Diagram(s)

sequenceDiagram
    actor Cron
    participant DB as Database
    participant Reconciler as reconcileNotionDisputes
    participant Notion
    participant SideEffects as fireDisputeSideEffects
    participant Triage as TriageAgent
    participant Ledger

    Cron->>Reconciler: Phase 10 trigger
    Reconciler->>DB: Query cc_tasks (type=legal, no dispute_id) limit 50
    DB-->>Reconciler: Task rows

    loop per task
        Reconciler->>Notion: validate notion origin / notion_task_id
        alt existing dispute by notion_task_id
            Reconciler->>DB: link task -> dispute
        else create dispute
            Reconciler->>DB: insert cc_disputes (set metadata.notion_task_id)
            DB-->>Reconciler: new dispute id
            Reconciler->>DB: link task -> dispute
            Reconciler->>SideEffects: invoke side effects (async)
            par side effects
                SideEffects->>Notion: linkDisputeToNotion()
                SideEffects->>Triage: scoreDisputeWithTriage()
                SideEffects->>Ledger: linkDisputeToLedger()
            and
                Notion-->>SideEffects: result
                Triage-->>SideEffects: result
                Ledger-->>SideEffects: result
            end
            SideEffects->>DB: update dispute metadata
        end
    end

    Reconciler-->>Cron: return created count
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through tasks and indexes bright,
I linked disputes by day and night,
To Notion, Triage, ledger too,
Metadata guards keep loops few,
A little hop — syncs now take flight 🥕✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: integrate disputes with Notion Task Triager and TriageAgent' accurately and concisely summarizes the primary change: integrating disputes with two external systems for automated sync and AI-powered scoring.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/dispute-sync-notion-triage

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (1)
src/lib/integrations.ts (1)

706-710: Include the standard source header on the Notion write path.

This new outbound call is the only one in src/lib/integrations.ts that skips X-Source-Service, so it loses the tracing/audit convention the rest of the clients follow.

💡 Suggested fix
         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',
+            'X-Source-Service': 'chittycommand',
           },

As per coding guidelines "Include X-Source-Service: chittycommand header on all outbound service calls".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/integrations.ts` around lines 706 - 710, The Notion write request's
headers block (the headers object that currently sets 'Authorization',
'Notion-Version', and 'Content-Type') is missing the required audit/tracing
header; add "X-Source-Service": "chittycommand" to that headers object so the
Notion write path follows the same outbound convention as other clients in
src/lib/integrations.ts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@migrations/0012_dispute_sync_indexes.sql`:
- Around line 5-7: Add a uniqueness constraint on the Notion page id to prevent
duplicate disputes by replacing the non-unique index
idx_cc_disputes_notion_task_id with a UNIQUE index on cc_disputes
((metadata->>'notion_task_id')) WHERE metadata->>'notion_task_id' IS NOT NULL,
and update the reconcileNotionDisputes code to handle duplicate-key races (use
INSERT ... ON CONFLICT DO NOTHING or catch unique-violation errors and proceed)
so concurrent runs cannot create two disputes for the same Notion page.

In `@src/lib/dispute-sync.ts`:
- Around line 192-195: The loop increments pushed for every call to
linkDisputeToNotion even when that function swallows errors and returns void;
change linkDisputeToNotion (or createTask inside it) to return a success
indicator (boolean or created Notion id) or rethrow on failure, then update the
for-loop that calls linkDisputeToNotion(dispute.id, ...) to only increment
pushed when the returned value signals a successful Notion link (e.g., true or
non-null), ensuring pushed accurately counts only actual pushes.
- Around line 71-89: The SELECT that populates legalTasks currently fetches up
to 50 legal/unlinked rows then skips those with no notion_page_id, which can
waste the batch; update the SQL in the legalTasks query to add AND
notion_page_id IS NOT NULL (or (notion_page_id IS NOT NULL)) so only
Notion-origin tasks are counted before ORDER/LIMIT, and also update the index
predicate for idx_cc_tasks_legal_unlinked to include notion_page_id IS NOT NULL
so the index matches the new query predicate.
- Around line 259-265: The triage payload omits zero-dollar disputes because
amount is set with a falsy check; update the ternary used in the
router.classifyDispute call inside src/lib/dispute-sync.ts (the amount property
in the object passed to router.classifyDispute) to explicitly check for
null/undefined (e.g., use != null or !== undefined) so that 0 is preserved and
converted with Number(dispute.amount_at_stake), otherwise set undefined.

In `@src/routes/bridge/disputes.ts`:
- Around line 49-52: The branch handling direction === 'from_notion' only runs
reconcileNotionDisputes(c.env, sql) which operates on the local cc_tasks cache
and thus misses pages not yet synced; update the flow so that when direction is
'from_notion' (or 'both') you first run a sync to pull pages from Notion into
cc_tasks (e.g., call syncNotionTasks(c.env, sql) or the existing sync function
used elsewhere) and only then call reconcileNotionDisputes(c.env, sql);
alternatively add an option/flag to reconcileNotionDisputes to fetch directly
from Notion and use that when direction is 'from_notion'—ensure you reference
reconcileNotionDisputes and syncNotionTasks in the change so the route will
reconcile newly created Notion pages immediately.
- Around line 29-31: The request currently swallows JSON parse errors by using
c.req.json().catch(() => ({})), which lets syncDirectionSchema default direction
to 'both' and trigger unwanted full syncs; change the logic in the block around
parsed = syncDirectionSchema.safeParse(...) so that JSON parse failures are
detected and rejected: explicitly await c.req.json() inside a try/catch (or
remove the .catch), and on any parse error return c.json({ error: 'Malformed
JSON' }, 400) before calling syncDirectionSchema.safeParse; update the return
path where parsed.success is false to keep the existing 400 response with
details from parsed.error.flatten() so malformed JSON never falls through to
start both sync directions.

---

Nitpick comments:
In `@src/lib/integrations.ts`:
- Around line 706-710: The Notion write request's headers block (the headers
object that currently sets 'Authorization', 'Notion-Version', and
'Content-Type') is missing the required audit/tracing header; add
"X-Source-Service": "chittycommand" to that headers object so the Notion write
path follows the same outbound convention as other clients in
src/lib/integrations.ts.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a09dd09f-380a-49a7-9b9f-5719acab66b1

📥 Commits

Reviewing files that changed from the base of the PR and between 1deeeb7 and b58c5e7.

📒 Files selected for processing (7)
  • migrations/0012_dispute_sync_indexes.sql
  • src/lib/cron.ts
  • src/lib/dispute-sync.ts
  • src/lib/integrations.ts
  • src/routes/bridge/disputes.ts
  • src/routes/bridge/index.ts
  • src/routes/disputes.ts

Comment on lines +5 to +7
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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Enforce one dispute per Notion page.

The reconciliation flow does an existence check and then inserts. Without a uniqueness guarantee on metadata->>'notion_task_id', concurrent cron/manual runs can materialize two disputes for the same Notion page and fan out duplicate triage/ledger side effects.

💡 Suggested migration tweak
-CREATE INDEX IF NOT EXISTS idx_cc_disputes_notion_task_id
+CREATE UNIQUE 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;

Please pair this with duplicate-key handling in reconcileNotionDisputes.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
CREATE UNIQUE 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;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@migrations/0012_dispute_sync_indexes.sql` around lines 5 - 7, Add a
uniqueness constraint on the Notion page id to prevent duplicate disputes by
replacing the non-unique index idx_cc_disputes_notion_task_id with a UNIQUE
index on cc_disputes ((metadata->>'notion_task_id')) WHERE
metadata->>'notion_task_id' IS NOT NULL, and update the reconcileNotionDisputes
code to handle duplicate-key races (use INSERT ... ON CONFLICT DO NOTHING or
catch unique-violation errors and proceed) so concurrent runs cannot create two
disputes for the same Notion page.

Comment on lines +71 to +89
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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Exclude non-Notion tasks before applying LIMIT 50.

Lines 71-79 fetch all legal/unlinked tasks, but Lines 87-89 skip rows without notion_page_id. If enough locally-created legal tasks have notion_page_id = NULL, every run can burn the whole batch on skips and never reach actual Notion-origin tasks.

💡 Suggested fix
   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 notion_page_id IS NOT NULL
       AND (metadata->>'dispute_id') IS NULL
     ORDER BY priority ASC, created_at ASC
     LIMIT 50
   `;

Also mirror the same predicate in idx_cc_tasks_legal_unlinked.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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;
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 notion_page_id IS NOT NULL
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;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/dispute-sync.ts` around lines 71 - 89, The SELECT that populates
legalTasks currently fetches up to 50 legal/unlinked rows then skips those with
no notion_page_id, which can waste the batch; update the SQL in the legalTasks
query to add AND notion_page_id IS NOT NULL (or (notion_page_id IS NOT NULL)) so
only Notion-origin tasks are counted before ORDER/LIMIT, and also update the
index predicate for idx_cc_tasks_legal_unlinked to include notion_page_id IS NOT
NULL so the index matches the new query predicate.

Comment on lines +192 to +195
for (const dispute of unlinked) {
try {
await linkDisputeToNotion(dispute.id as string, dispute as unknown as DisputeCore, env, sql);
pushed++;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only count successful Notion links as pushed.

linkDisputeToNotion() catches its own failures and returns void, so Line 195 increments pushed even when credentials are missing or createTask() returns null. The bridge response can therefore report successful pushes that never happened.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/dispute-sync.ts` around lines 192 - 195, The loop increments pushed
for every call to linkDisputeToNotion even when that function swallows errors
and returns void; change linkDisputeToNotion (or createTask inside it) to return
a success indicator (boolean or created Notion id) or rethrow on failure, then
update the for-loop that calls linkDisputeToNotion(dispute.id, ...) to only
increment pushed when the returned value signals a successful Notion link (e.g.,
true or non-null), ensuring pushed accurately counts only actual pushes.

Comment on lines +259 to +265
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,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Preserve 0 when building the triage payload.

Line 264 treats 0 as falsy and omits amount entirely. That changes the TriageAgent input for zero-dollar disputes.

💡 Suggested fix
-      amount: dispute.amount_at_stake ? Number(dispute.amount_at_stake) : undefined,
+      amount: dispute.amount_at_stake == null ? undefined : Number(dispute.amount_at_stake),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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,
const result = await router.classifyDispute({
entity_id: disputeId,
entity_type: 'dispute',
title: dispute.title,
dispute_type: dispute.dispute_type,
amount: dispute.amount_at_stake == null ? undefined : Number(dispute.amount_at_stake),
description: dispute.description || undefined,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/dispute-sync.ts` around lines 259 - 265, The triage payload omits
zero-dollar disputes because amount is set with a falsy check; update the
ternary used in the router.classifyDispute call inside src/lib/dispute-sync.ts
(the amount property in the object passed to router.classifyDispute) to
explicitly check for null/undefined (e.g., use != null or !== undefined) so that
0 is preserved and converted with Number(dispute.amount_at_stake), otherwise set
undefined.

Comment on lines +29 to +31
const parsed = syncDirectionSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) {
return c.json({ error: 'Invalid request', details: parsed.error.flatten() }, 400);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject malformed JSON instead of defaulting to a full sync.

Line 29 turns any JSON parse failure into {}, and the schema default then resolves direction to 'both'. A bad request body can therefore kick off both sync directions instead of returning 400.

💡 Suggested fix
-disputesBridgeRoutes.post('/sync-notion', async (c) => {
-  const parsed = syncDirectionSchema.safeParse(await c.req.json().catch(() => ({})));
+disputesBridgeRoutes.post('/sync-notion', async (c) => {
+  const rawBody = await c.req.text();
+  let raw: unknown = {};
+
+  if (rawBody.trim()) {
+    try {
+      raw = JSON.parse(rawBody);
+    } catch {
+      return c.json({ error: 'Invalid JSON body' }, 400);
+    }
+  }
+
+  const parsed = syncDirectionSchema.safeParse(raw);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const parsed = syncDirectionSchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) {
return c.json({ error: 'Invalid request', details: parsed.error.flatten() }, 400);
disputesBridgeRoutes.post('/sync-notion', async (c) => {
const rawBody = await c.req.text();
let raw: unknown = {};
if (rawBody.trim()) {
try {
raw = JSON.parse(rawBody);
} catch {
return c.json({ error: 'Invalid JSON body' }, 400);
}
}
const parsed = syncDirectionSchema.safeParse(raw);
if (!parsed.success) {
return c.json({ error: 'Invalid request', details: parsed.error.flatten() }, 400);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/bridge/disputes.ts` around lines 29 - 31, The request currently
swallows JSON parse errors by using c.req.json().catch(() => ({})), which lets
syncDirectionSchema default direction to 'both' and trigger unwanted full syncs;
change the logic in the block around parsed = syncDirectionSchema.safeParse(...)
so that JSON parse failures are detected and rejected: explicitly await
c.req.json() inside a try/catch (or remove the .catch), and on any parse error
return c.json({ error: 'Malformed JSON' }, 400) before calling
syncDirectionSchema.safeParse; update the return path where parsed.success is
false to keep the existing 400 response with details from parsed.error.flatten()
so malformed JSON never falls through to start both sync directions.

Comment on lines +49 to +52
if (direction === 'from_notion' || direction === 'both') {
try {
reconciled = await reconcileNotionDisputes(c.env, sql);
} catch (err) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

from_notion only reconciles the local cache.

reconcileNotionDisputes() reads cc_tasks, so this branch will not see pages created in Notion until a separate syncNotionTasks() run has already populated the table. For a manual /sync-notion endpoint, that makes direction: 'from_notion' incomplete.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/bridge/disputes.ts` around lines 49 - 52, The branch handling
direction === 'from_notion' only runs reconcileNotionDisputes(c.env, sql) which
operates on the local cc_tasks cache and thus misses pages not yet synced;
update the flow so that when direction is 'from_notion' (or 'both') you first
run a sync to pull pages from Notion into cc_tasks (e.g., call
syncNotionTasks(c.env, sql) or the existing sync function used elsewhere) and
only then call reconcileNotionDisputes(c.env, sql); alternatively add an
option/flag to reconcileNotionDisputes to fetch directly from Notion and use
that when direction is 'from_notion'—ensure you reference
reconcileNotionDisputes and syncNotionTasks in the change so the route will
reconcile newly created Notion pages immediately.

- 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 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

  1. @coderabbitai review
  2. @copilot review
  3. @codex review
  4. @claude review
    Adversarial review request: evaluate security, policy bypass paths, regression risk, and merge-gating bypass attempts.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== src/lib/dispute-sync.ts ==="
cat src/lib/dispute-sync.ts

echo ""
echo "=== src/lib/integrations.ts ==="
cat src/lib/integrations.ts

echo ""
echo "=== src/routes/bridge/disputes.ts ==="
cat src/routes/bridge/disputes.ts

echo ""
echo "=== src/routes/bridge/index.ts ==="
cat src/routes/bridge/index.ts

echo ""
echo "=== src/routes/disputes.ts ==="
cat src/routes/disputes.ts

echo ""
echo "=== src/lib/cron.ts ==="
cat src/lib/cron.ts

echo ""
echo "=== migrations/0012_dispute_sync_indexes.sql ==="
cat migrations/0012_dispute_sync_indexes.sql

echo ""
echo "=== bridgeAuthMiddleware definition ==="
rg -n "bridgeAuthMiddleware" --type ts -A 20 -B 2

echo ""
echo "=== Auth middleware patterns ==="
rg -n "authMiddleware\|bridgeAuth\|bearerAuth\|apiKey\|API_KEY\|BRIDGE_SECRET" --type ts -C 3

echo ""
echo "=== KV credential resolution in notionClient ==="
rg -n "COMMAND_KV\|notion_token\|notion:token\|notion:database" --type ts -C 3

echo ""
echo "=== waitUntil usage ==="
rg -n "waitUntil" --type ts -C 3

echo ""
echo "=== SQL query patterns - check for injection risks ==="
rg -n "sql\`\|sql(" --type ts -C 2

echo ""
echo "=== Error handling - check for secret leakage ==="
rg -n "console\.error\|console\.log" --type ts -C 2 src/lib/dispute-sync.ts src/lib/integrations.ts 2>/dev/null || rg -n "console\.error\|console\.log" --type ts -C 2

echo ""
echo "=== Loop guard metadata checks ==="
rg -n "notion_task_id\|ledger_case_id\|dispute_id\|notion_synced\|from_notion" --type ts -C 3

echo ""
echo "=== reconcileNotionDisputes - check LIMIT and pagination ==="
rg -n "LIMIT\|limit\|reconcile" --type ts -C 3

Length of output: 83022


@github-actions[bot]: Adversarial security review of PR #26 — findings organized by severity below.


🔴 Critical / Merge Blockers

1. Bridge Auth Escalation — Any Authenticated User Gets admin Scopes on Bridge Routes

File: src/middleware/auth.ts (Lines 74–78)

// Fall back to regular user auth
const userId = await c.env.COMMAND_KV.get(`auth:token:${token}`);
if (userId) {
  c.set('userId', userId);
  c.set('scopes', ['admin']);   // ← any user token gets admin scopes on /api/bridge/*

Any valid user session token can reach POST /api/bridge/disputes/sync-notion (and every other bridge route). The bridgeAuthMiddleware was presumably intended only for service-to-service calls, but the fallback gives ordinary user tokens unchecked admin scopes. A logged-in user can trigger bulk Notion writes, bulk dispute creation, and ledger case creation without any additional authorization check. If bridge routes are meant to be service-only, the fallback path must be removed or a scope check (if (!scopes.includes('bridge')) return 401) must be added to each bridge handler.


2. TOCTOU Race → Duplicate Disputes from Same Notion Page (No UNIQUE Constraint)

File: src/lib/dispute-sync.ts (reconcileNotionDisputes) + migrations/0012_dispute_sync_indexes.sql

The deduplication logic is:

const [existing] = await sql`
  SELECT id FROM cc_disputes WHERE metadata->>'notion_task_id' = ${notionPageId} LIMIT 1
`;
if (existing) { /* link back and skip */ continue; }
// ...
const [dispute] = await sql`INSERT INTO cc_disputes ...`;

The migration adds only an index, not a UNIQUE constraint. Two concurrent executions (e.g., cron Phase 10 running while a bridge from_notion call is in flight) will both pass the SELECT check and both INSERT, creating duplicate disputes for the same notionPageId. Fix: add a unique expression index or a generated column with a unique constraint:

CREATE UNIQUE INDEX IF NOT EXISTS uq_cc_disputes_notion_task_id
  ON cc_disputes ((metadata->>'notion_task_id'))
  WHERE metadata->>'notion_task_id' IS NOT NULL;

Then handle ON CONFLICT DO NOTHING in the INSERT.


3. Unvalidated External Priority Written Directly to cc_disputes.priority

File: src/lib/dispute-sync.ts (scoreDisputeWithTriage, Line ~220)

await sql`
  UPDATE cc_disputes
  SET priority = ${result.priority},   // ← no bounds check
      metadata = ...
  WHERE id = ${disputeId}
`;

result is the raw response from an external HTTP service (ChittyRouter TriageAgent). If that service is misconfigured, compromised, or its response schema changes, it can write any numeric value (e.g., -1, 0, 99999, NaN) directly to the priority column. This can corrupt triage ordering for all disputes. Fix: clamp and validate before writing:

const safePriority = Math.max(1, Math.min(10, Math.round(Number(result.priority))));
if (!Number.isFinite(safePriority)) throw new Error('Invalid priority from TriageAgent');

🟠 High Severity

4. PATCH /api/disputes/:id Clears Loop-Guard Flags — Enables Duplicate Side Effects

File: src/routes/disputes.ts (PATCH handler)

metadata = COALESCE(${body.metadata ? JSON.stringify(body.metadata) : null}::jsonb, metadata)

If a caller sends PATCH /api/disputes/:id with { "metadata": {} }, the entire metadata JSONB is replaced with an empty object, wiping notion_task_id, ledger_case_id, and triage_at. Subsequently calling POST /api/bridge/disputes/sync-notion (accessible to any authenticated user per finding #1) will re-run all side effects: a new Notion page is created, a new ledger case is created, and triage is re-scored. This creates unbounded duplicates in Notion and ChittyLedger.

Fix options:

  • Change the PATCH to a merge (||) rather than a replacement for metadata fields.
  • Use jsonb_set to allow individual key updates.
  • Block deletion of known loop-guard keys (notion_task_id, ledger_case_id) in the update schema.

5. User-Supplied metadata in POST Silently Suppresses Side Effects

File: src/routes/disputes.ts (POST handler) + src/lib/dispute-sync.ts (fireDisputeSideEffects)

The newly inserted dispute.metadata is read back from the DB after INSERT, so it reflects whatever the user passed in:

const meta = (dispute.metadata || {}) as Record<string, unknown>;
if (!meta.notion_task_id) { tasks.push(linkDisputeToNotion(...)); }
if (!meta.ledger_case_id) { tasks.push(linkDisputeToLedger(...)); }

A caller who sends POST /api/disputes with { ..., "metadata": { "notion_task_id": "fake-id", "ledger_case_id": "fake-id" } } will silently skip both Notion linking and ledger case creation. There is no audit trail of this suppression. The loop-guard check should only apply to metadata set by the system, not user input. Fix: strip known system-reserved metadata keys from body.metadata in createDisputeSchema or in the POST handler before INSERT.


🟡 Medium Severity

6. No Rate Limiting on Bridge Sync Endpoint → Resource Exhaustion

File: src/routes/bridge/disputes.ts

A single authenticated call with { "direction": "both" } triggers:

  • Up to 50 Notion API calls (write path)
  • Up to 50 dispute INSERTs + TriageAgent calls + ledger calls
  • Associated DB writes for each

There is no per-IP, per-token, or global rate limit. A script rotating through valid user tokens (per finding #1) can continuously exhaust Notion API quota, TriageAgent capacity, and DB write throughput. The LIMIT 50 cap mitigates worst-case per-call cost but not call frequency.


7. No Pagination / Cursor in Sync Functions — Backlog May Never Drain

File: src/lib/dispute-sync.ts (all three exported functions)

All three public functions cap at LIMIT 50 with no cursor tracking. If there are 200 unlinked disputes, four sequential bridge calls are required, and there is no built-in mechanism to page through the remainder. The cron Phase 10 runs once daily — a sudden spike of 100+ Notion legal tasks will lag multiple days behind. Consider a last_processed_id cursor stored in KV, or increase LIMIT with a cost/latency-aware cap.


8. counterparty: 'Unknown' Hardcoded in Auto-Created Disputes

File: src/lib/dispute-sync.ts (reconcileNotionDisputes, Line ~115)

const [dispute] = await sql`
  INSERT INTO cc_disputes (title, counterparty, ...)
  VALUES (${task.title as string}, 'Unknown', ...)
`;

Disputes auto-created from Notion tasks permanently carry counterparty = 'Unknown' with no mechanism to enrich this field from the Notion task properties. This is a data quality and correctness issue — legal disputes with no counterparty information may be unactionable.


🔵 Informational

9. Notion API Error Body Logged to Console

File: src/lib/integrations.ts

const errBody = await res.text().catch(() => '');
console.error(`[notion] createTask failed: ${res.status} ${errBody}`);

Notion API error responses can include token scoping details. In a Cloudflare Worker context this goes to the CF dashboard (not public), but it's worth noting this could surface in any log aggregation pipeline depending on the deployment.


Summary Table

# Severity Finding File
1 🔴 Critical Any user token gains admin scopes on all bridge routes src/middleware/auth.ts
2 🔴 Critical TOCTOU race → duplicate disputes; no UNIQUE constraint migrations/0012_dispute_sync_indexes.sql
3 🔴 Critical Unvalidated external priority written directly to DB src/lib/dispute-sync.ts
4 🟠 High PATCH clears loop-guard flags → unlimited duplicate side effects src/routes/disputes.ts
5 🟠 High User-supplied metadata silently suppresses Notion/Ledger linkage src/routes/disputes.ts
6 🟡 Medium No rate limiting on bridge sync endpoint src/routes/bridge/disputes.ts
7 🟡 Medium LIMIT 50 with no cursor → backlog drain requires manual repeated calls src/lib/dispute-sync.ts
8 🟡 Medium counterparty: 'Unknown' hardcoded in auto-created disputes src/lib/dispute-sync.ts
9 🔵 Info Notion API error body logged verbatim src/lib/integrations.ts

Findings #1, #2, and #3 are merge-blocking per the repository's must-fix policy. Findings #4 and #5 together create a reliable privilege abuse path once #1 is exploited.


🧠 Learnings used
Learnt from: CR
Repo: chittyos/chittycommand PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-04T04:42:57.380Z
Learning: Resolve must-fix review comments (security, correctness, compliance, merge blockers) before merge

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/lib/cron.ts (1)

653-653: Prefer nullish fallback for numeric priority.

Line 653 uses ||, so an explicit numeric 0 would be treated as missing and replaced by the fallback. ?? keeps the same behavior for absent fields without changing valid numeric values.

♻️ Suggested change
-      const priority = extractNotionNumber(props['Priority 1']) || extractNotionNumber(props['Priority']) || 5;
+      const priority =
+        extractNotionNumber(props['Priority 1']) ??
+        extractNotionNumber(props['Priority']) ??
+        5;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/cron.ts` at line 653, The priority assignment uses || which treats 0
as falsy; update the priority computation so it uses nullish coalescing (??)
instead: call extractNotionNumber(props['Priority 1']) ??
extractNotionNumber(props['Priority']) ?? 5, preserving valid numeric 0 while
still falling back when values are null/undefined; change the expression where
the variable priority is declared to use ?? and keep references to
extractNotionNumber, props['Priority 1'], and props['Priority'] unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/cron.ts`:
- Around line 125-135: reconcileNotionDisputes can race and create duplicate
cc_disputes because there is no DB-enforced uniqueness; add a DB-level dedupe by
creating a UNIQUE expression index (or constraint) on the notion task id
extracted from metadata (e.g. UNIQUE ((metadata->>'notion_task_id'))) for the
cc_disputes table via a new migration (or update
migrations/0012_dispute_sync_indexes.sql), and then update
reconcileNotionDisputes to use an idempotent insert (INSERT ... ON CONFLICT DO
NOTHING or handle unique-violation error) so the cron path and bridge route
cannot both create duplicates; reference reconcileNotionDisputes, cc_disputes,
migrations/0012_dispute_sync_indexes.sql and migrations/0002_command_legal.sql
when making these changes.

---

Nitpick comments:
In `@src/lib/cron.ts`:
- Line 653: The priority assignment uses || which treats 0 as falsy; update the
priority computation so it uses nullish coalescing (??) instead: call
extractNotionNumber(props['Priority 1']) ??
extractNotionNumber(props['Priority']) ?? 5, preserving valid numeric 0 while
still falling back when values are null/undefined; change the expression where
the variable priority is declared to use ?? and keep references to
extractNotionNumber, props['Priority 1'], and props['Priority'] unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c605eaf9-1390-453e-a275-2be2cdc1bf91

📥 Commits

Reviewing files that changed from the base of the PR and between b58c5e7 and 25b8085.

📒 Files selected for processing (3)
  • src/lib/cron.ts
  • src/lib/integrations.ts
  • wrangler.toml
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/lib/integrations.ts

Comment on lines +125 to +135
// 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);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add a DB-enforced dedupe guard before running this on cron.

reconcileNotionDisputes() in src/lib/dispute-sync.ts:67-171 currently does a SELECT on metadata->>'notion_task_id' and then an INSERT, but the schema only has a non-unique index for that key in migrations/0012_dispute_sync_indexes.sql:1-10, and cc_disputes has no unique constraint in migrations/0002_command_legal.sql:28-44. Now that this path also runs on the daily cron, it can race with the bridge route or another worker and create duplicate disputes plus duplicate downstream side effects for the same Notion task.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/cron.ts` around lines 125 - 135, reconcileNotionDisputes can race and
create duplicate cc_disputes because there is no DB-enforced uniqueness; add a
DB-level dedupe by creating a UNIQUE expression index (or constraint) on the
notion task id extracted from metadata (e.g. UNIQUE
((metadata->>'notion_task_id'))) for the cc_disputes table via a new migration
(or update migrations/0012_dispute_sync_indexes.sql), and then update
reconcileNotionDisputes to use an idempotent insert (INSERT ... ON CONFLICT DO
NOTHING or handle unique-violation error) so the cron path and bridge route
cannot both create duplicates; reference reconcileNotionDisputes, cc_disputes,
migrations/0012_dispute_sync_indexes.sql and migrations/0002_command_legal.sql
when making these changes.

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 <noreply@anthropic.com>
@github-actions
Copy link

github-actions bot commented Mar 9, 2026

  1. @coderabbitai review
  2. @copilot review
  3. @codex review
  4. @claude review
    Adversarial review request: evaluate security, policy bypass paths, regression risk, and merge-gating bypass attempts.

@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== src/middleware/auth.ts ==="
cat src/middleware/auth.ts

echo ""
echo "=== src/lib/dispute-sync.ts ==="
cat src/lib/dispute-sync.ts

echo ""
echo "=== src/lib/integrations.ts ==="
cat src/lib/integrations.ts

echo ""
echo "=== src/routes/disputes.ts ==="
cat src/routes/disputes.ts

echo ""
echo "=== src/routes/bridge/disputes.ts ==="
cat src/routes/bridge/disputes.ts

echo ""
echo "=== src/lib/cron.ts ==="
cat src/lib/cron.ts

echo ""
echo "=== migrations/0012_dispute_sync_indexes.sql ==="
cat migrations/0012_dispute_sync_indexes.sql

echo ""
echo "=== src/routes/bridge/index.ts ==="
cat src/routes/bridge/index.ts

echo ""
echo "=== Check all dispute-related schema/zod validation ==="
rg -n "createDisputeSchema\|disputeSchema\|metadata.*z\." --type ts -A 10 -B 2

echo ""
echo "=== Check PATCH handler for disputes ==="
rg -n "PATCH\|patch" --type ts -C 5 src/routes/disputes.ts

echo ""
echo "=== Check all console.error/log in dispute-sync and integrations ==="
rg -n "console\.(error|log|warn)" --type ts src/lib/dispute-sync.ts src/lib/integrations.ts

echo ""
echo "=== Check for rate limiting middleware ==="
rg -n "rateLimit\|rate_limit\|throttle" --type ts -C 3

echo ""
echo "=== Check all bridge route middleware usage ==="
rg -n "bridgeAuth\|bridge.*use\|use.*bridge" --type ts -C 5

echo ""
echo "=== Check for UNIQUE constraints in all migrations ==="
rg -rn "UNIQUE\|unique" migrations/

echo ""
echo "=== Check TriageAgent response parsing ==="
rg -n "classifyDispute\|triage.*result\|result\.priority\|result\.severity" --type ts -C 5

echo ""
echo "=== Check for input sanitization / validation on external responses ==="
rg -n "z\.parse\|\.safeParse\|schema\.parse\|zod" --type ts src/lib/dispute-sync.ts src/lib/integrations.ts

echo ""
echo "=== Check all SQL insert patterns for dispute creation ==="
rg -n "INSERT INTO cc_disputes" --type ts -A 10 -B 3

echo ""
echo "=== Check metadata handling in POST handler ==="
rg -n "metadata" --type ts -C 3 src/routes/disputes.ts

echo ""
echo "=== Check for any SSRF-relevant URL construction ==="
rg -n "fetch\|ROUTER_URL\|NOTION_URL\|https?://" --type ts src/lib/integrations.ts src/lib/dispute-sync.ts -C 3

echo ""
echo "=== Check for secrets in wrangler.toml or env vars ==="
cat wrangler.toml

Length of output: 90655


@github-actions[bot] — Independent adversarial review of PR #26 complete. I've verified all nine prior findings against the live source and identified additional issues. Full breakdown below.


✅ Confirmation of Prior Findings + New Issues

🔴 Confirmed Critical / Merge Blockers

Prior Finding #1Confirmed & Expanded: Bridge auth escalation

src/middleware/auth.ts Lines 74–78:

const userId = await c.env.COMMAND_KV.get(`auth:token:${token}`);
if (userId) {
  c.set('userId', userId);
  c.set('scopes', ['admin']);  // any KV user token → admin on all bridge routes
  return next();
}

Also confirmed: The ChittyAuth fallback path (Lines 83–91) in bridgeAuthMiddleware has no scope gate either:

const identity = await res.json() as { user_id: string; scopes: string[] };
c.set('userId', identity.user_id);
c.set('scopes', identity.scopes || []);
return next();  // ← passes regardless of what scopes ChittyAuth returns

Any token validated by ChittyAuth — even a low-privilege user — reaches bridge routes. Neither the KV path nor the ChittyAuth path checks scopes.includes('bridge'). Fix: add a scope guard:

// After both auth paths resolve scopes:
const scopes = c.get('scopes') as string[];
if (!scopes.includes('bridge') && !scopes.includes('admin')) {
  return c.json({ error: 'Insufficient scope for bridge access' }, 403);
}

Prior Finding #2Confirmed: TOCTOU → duplicate disputes

src/lib/dispute-sync.ts Lines 97–129: SELECT then INSERT with only a regular index (not UNIQUE) in migrations/0012_dispute_sync_indexes.sql. Concurrent cron + bridge calls will produce duplicate disputes.

Fix — add to migration:

CREATE UNIQUE INDEX IF NOT EXISTS uq_cc_disputes_notion_task_id
  ON cc_disputes ((metadata->>'notion_task_id'))
  WHERE metadata->>'notion_task_id' IS NOT NULL;

And update the INSERT:

const [dispute] = await sql`
  INSERT INTO cc_disputes (title, counterparty, dispute_type, priority, description, metadata)
  VALUES (...)
  ON CONFLICT ((metadata->>'notion_task_id')) DO NOTHING
  RETURNING id, title, ...
`;
if (!dispute) continue; // lost the race — existing dispute handles it

Prior Finding #3Confirmed & Expanded: Unvalidated external priority written to DB

src/lib/dispute-sync.ts Lines 278–285:

await sql`
  UPDATE cc_disputes
  SET priority = ${result.priority},   // no bounds check, no type check
      metadata = ... ${JSON.stringify({
        triage_severity: result.severity,   // also unvalidated
        triage_labels: result.labels,        // could be non-array
        triage_reasoning: result.reasoning || null,  // unbounded string stored verbatim
      })}::jsonb

There is zero schema validation on the TriageAgent response — no Zod, no type narrowing beyond as { severity: number; priority: number; labels: string[]; reasoning?: string }. If the TriageAgent returns { priority: "critical", severity: null, labels: "phishing" }, it writes invalid data silently. Fix:

import { z } from 'zod';
const triageResultSchema = z.object({
  severity: z.number().int().min(1).max(10),
  priority: z.number().int().min(1).max(10),
  labels: z.array(z.string().max(100)).max(20),
  reasoning: z.string().max(2000).optional(),
});
const parsed = triageResultSchema.safeParse(result);
if (!parsed.success) {
  console.error(`[dispute-sync:triage] Invalid response schema for ${disputeId}:`, parsed.error);
  return;
}
const safe = parsed.data;

🔴 New Critical Finding

NEW Finding N: disputes.chitty.cc exposes the entire Worker — contradicts PR description

wrangler.toml:

routes = [
  { pattern = "command.chitty.cc", custom_domain = true },
  { pattern = "disputes.chitty.cc", custom_domain = true }  ← full domain binding
]

The PR description states the pre-deploy step is to set up a "Cloudflare redirect from disputes.chitty.cc to command.chitty.cc/api/disputes (307)". The actual wrangler.toml deploys a full custom domain pointing to the Worker — meaning every route in the application is reachable at disputes.chitty.cc, including:

  • disputes.chitty.cc/api/bridge/disputes/sync-notion (bridge trigger)
  • disputes.chitty.cc/mcp/* (MCP tools, non-production auth bypass)
  • All other admin and internal routes

If the intended behavior is a scoped redirect, the redirect logic must be implemented inside the Worker (e.g., a route-level redirect handler for the disputes.chitty.cc hostname), not just by adding it as a custom domain alias.


🟠 Confirmed High Severity

Prior Finding #4Confirmed: PATCH clears loop-guard flags

src/routes/disputes.ts Line 138: COALESCE(${body.metadata ? JSON.stringify(body.metadata) : null}::jsonb, metadata) — sending { "metadata": {} } replaces the entire metadata JSONB, wiping notion_task_id, ledger_case_id, and triage_at. Subsequent bridge sync will re-run all side effects.

Fix — use || (merge) instead of replacement:

metadata = metadata || ${body.metadata ? JSON.stringify(body.metadata) : '{}'}::jsonb

Prior Finding #5Confirmed: User-supplied metadata bypasses loop guards

src/routes/disputes.ts Lines 80, 94: User POST body metadata is stored to the DB, then the DB-returned dispute.metadata (which reflects user input) is passed directly to fireDisputeSideEffects. The loop guard if (!meta.notion_task_id) is defeated by a caller sending { "metadata": { "notion_task_id": "fake", "ledger_case_id": "fake" } }.

Fix — strip reserved system keys before INSERT:

const { notion_task_id, ledger_case_id, triage_at, triage_severity, ...userMeta } = body.metadata || {};
// INSERT using userMeta only

🟡 Confirmed Medium Severity

Prior Findings #6, #7, #8All Confirmed


🔵 Confirmed + New Informational

Prior Finding #9Confirmed: Notion error body logged verbatim

src/lib/integrations.ts Line 720: console.error(\[notion] createTask failed: ${res.status} ${errBody}`)` — Notion error bodies can include token scope detail.

NEW Finding R: scoreDisputeWithTriage has no idempotency guard

// TriageAgent scoring — always runs  ← explicit comment
tasks.push(scoreDisputeWithTriage(dispute.id, dispute, env, sql));

Unlike Notion and Ledger linkage (which check notion_task_id/ledger_case_id), triage scoring has no triage_at check. A future code path that calls fireDisputeSideEffects twice on the same dispute (e.g., a retry) will re-score and overwrite triage results. Low blast radius now, but a latent correctness bug. Consider adding:

if (!meta.triage_at) {
  tasks.push(scoreDisputeWithTriage(dispute.id, dispute, env, sql));
}

NEW Finding S: Cloudflare infrastructure IDs committed in wrangler.toml

account_id, Hyperdrive binding id, and KV namespace id are committed in plaintext to the repository. While these are not secret tokens, they enumerate infrastructure and should ideally be injected via CI/CD environment variables or treated as sensitive for public repos.


Summary

# Severity Status Finding
1 🔴 Critical ✅ Confirmed + Expanded Any user/ChittyAuth token gets unchecked bridge access
2 🔴 Critical ✅ Confirmed TOCTOU → duplicate disputes; no UNIQUE constraint
3 🔴 Critical ✅ Confirmed + Expanded No schema validation on TriageAgent response; raw values written to DB
N 🔴 Critical 🆕 New disputes.chitty.cc exposes full Worker, not a 307 redirect
4 🟠 High ✅ Confirmed PATCH metadata: {} wipes loop-guard flags
5 🟠 High ✅ Confirmed User-supplied metadata silently suppresses Notion/Ledger linkage
6 🟡 Medium ✅ Confirmed No rate limiting on bridge sync endpoint
7 🟡 Medium ✅ Confirmed LIMIT 50, no cursor — backlog drains slowly
8 🟡 Medium ✅ Confirmed counterparty: 'Unknown' hardcoded, no enrichment path
9 🔵 Info ✅ Confirmed Notion error body logged verbatim
R 🔵 Info 🆕 New scoreDisputeWithTriage lacks idempotency guard
S 🔵 Info 🆕 New Infra IDs committed in wrangler.toml

Findings #1, #2, #3, and #N are merge-blocking per the repository's must-fix policy. Findings #4 and #5 together create a reliable side-effect manipulation path once #1 is exploited.


🧠 Learnings used
Learnt from: CR
Repo: chittyos/chittycommand PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-04T04:42:57.380Z
Learning: Resolve must-fix review comments (security, correctness, compliance, merge blockers) before merge

Learnt from: CR
Repo: chittyos/chittycommand PR: 0
File: .github/copilot-instructions.md:0-0
Timestamp: 2026-03-04T04:42:57.380Z
Learning: One concern area per PR: scope, risk/blast radius, test evidence, rollback plan, and migration impact

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
docs/notion-task-triager-instructions.md (3)

58-68: Clarify the best-effort nature of the automated workflow.

The workflow description implies that all steps (cron sync, TriageAgent scoring, ChittyLedger case creation) always succeed together. However, context snippet 2 (src/lib/dispute-sync.ts:118-162) and the PR test plan indicate that TriageAgent and Ledger operations are non-blocking—if they fail, the dispute still syncs successfully. Users should understand that:

  1. The dispute will always be created in ChittyCommand (guaranteed)
  2. TriageAgent scoring and ChittyLedger case creation are best-effort (may fail without blocking the sync)
  3. Failures in these side effects won't prevent the dispute from appearing in the dashboard

Consider adding a note to manage user expectations about partial failures.

📝 Suggested clarification
 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
+2. **TriageAgent** attempts to score the dispute for severity and priority (best-effort)
+3. **ChittyLedger** attempts to create a case record for chain-of-custody tracking (best-effort)
 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.
+You do NOT need to create anything in ChittyCommand directly — the sync is automatic. If TriageAgent or Ledger fail, the dispute will still be created and visible in the dashboard.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/notion-task-triager-instructions.md` around lines 58 - 68, Update the
"What Happens After Creation" section to state that the cron sync into
cc_disputes (ChittyCommand) is guaranteed but the subsequent TriageAgent scoring
and ChittyLedger case creation are best-effort and non-blocking: mention that
TriageAgent (e.g., TriageAgent.score/triage call in src/lib/dispute-sync.ts) and
ChittyLedger.createCase may fail without preventing the dispute from being
created/synced and appearing in the ChittyCommand dashboard, and advise users
that partial failures of these side effects will not stop the dispute from being
visible in the dashboard.

34-47: Consider documenting tag validation behavior.

The tag guidelines specify exact tag values (e.g., Insurance-claim, Court-filing), but context snippet 3 (src/lib/cron.ts:645-675) shows that tags are extracted and stored without validation against this list. Users might create typos (e.g., "Insuranceclaim") or custom tags, which could break downstream filtering or reporting. Consider either:

  1. Adding a note that these are recommended tags and custom tags are permitted (if that's the intent), or
  2. Implementing tag validation during Notion sync to ensure only these values are accepted
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/notion-task-triager-instructions.md` around lines 34 - 47, The docs list
a fixed set of tags but the Notion sync code in src/lib/cron.ts (tag extraction
block around lines 645-675) currently accepts any tag; update behavior by
either: (A) editing docs/notion-task-triager-instructions.md to explicitly state
these are recommended tags and that custom tags are permitted, or (B) adding
validation in the tag-extraction logic in src/lib/cron.ts to normalize
(trim/case) incoming tags, check them against the canonical list (Dispute,
Insurance-claim, Court-filing, Property-issue, Vendor-dispute, Legal-deadline,
Payment, Tax, Utility), and then either reject/replace unknown tags (e.g., map
to "Custom" or skip) while logging a warning so downstream filters remain
consistent. Ensure the implementation uses the existing tag-extraction
function/block so behavior is centralized and add a concise doc note if choosing
validation.

48-57: Consider noting priority bounds are advisory.

The priority guidance suggests a 1-10 scale, but the code does not enforce these bounds during Notion sync (context snippet 3, line 654 accepts any number with a fallback to 5 if missing). While this is primarily user guidance, you might consider adding a note that values outside 1-10 are technically permitted but may behave unexpectedly in ChittyCommand's priority sorting or filtering.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/notion-task-triager-instructions.md` around lines 48 - 57, Update the
"Priority Guidance" section to explicitly state that the 1-10 bounds are
advisory: note that the Notion sync parsing logic accepts any numeric priority
(with a fallback to 5 when missing) so values outside 1-10 are technically
permitted but may behave unexpectedly in ChittyCommand's priority
sorting/filtering; reference the priority field parsing in the Notion sync
implementation (the code that reads/returns the priority value) so maintainers
know why the advisory is needed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/notion-task-triager-instructions.md`:
- Around line 20-33: The docs fail to state that the counterparty property is
auto-populated as "Unknown" for disputes created from Notion; add a new row or a
short note directly below the Required Properties table stating: "counterparty —
defaults to 'Unknown' for Notion-imported disputes; update manually in
ChittyCommand if known." Reference the code that enforces this default (the
assignment setting counterparty = "Unknown" in the dispute-sync logic) so
readers know why the field may need manual correction after sync.

---

Nitpick comments:
In `@docs/notion-task-triager-instructions.md`:
- Around line 58-68: Update the "What Happens After Creation" section to state
that the cron sync into cc_disputes (ChittyCommand) is guaranteed but the
subsequent TriageAgent scoring and ChittyLedger case creation are best-effort
and non-blocking: mention that TriageAgent (e.g., TriageAgent.score/triage call
in src/lib/dispute-sync.ts) and ChittyLedger.createCase may fail without
preventing the dispute from being created/synced and appearing in the
ChittyCommand dashboard, and advise users that partial failures of these side
effects will not stop the dispute from being visible in the dashboard.
- Around line 34-47: The docs list a fixed set of tags but the Notion sync code
in src/lib/cron.ts (tag extraction block around lines 645-675) currently accepts
any tag; update behavior by either: (A) editing
docs/notion-task-triager-instructions.md to explicitly state these are
recommended tags and that custom tags are permitted, or (B) adding validation in
the tag-extraction logic in src/lib/cron.ts to normalize (trim/case) incoming
tags, check them against the canonical list (Dispute, Insurance-claim,
Court-filing, Property-issue, Vendor-dispute, Legal-deadline, Payment, Tax,
Utility), and then either reject/replace unknown tags (e.g., map to "Custom" or
skip) while logging a warning so downstream filters remain consistent. Ensure
the implementation uses the existing tag-extraction function/block so behavior
is centralized and add a concise doc note if choosing validation.
- Around line 48-57: Update the "Priority Guidance" section to explicitly state
that the 1-10 bounds are advisory: note that the Notion sync parsing logic
accepts any numeric priority (with a fallback to 5 when missing) so values
outside 1-10 are technically permitted but may behave unexpectedly in
ChittyCommand's priority sorting/filtering; reference the priority field parsing
in the Notion sync implementation (the code that reads/returns the priority
value) so maintainers know why the advisory is needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1b1c22c3-1939-46af-bc3b-dfbc7a8b40cf

📥 Commits

Reviewing files that changed from the base of the PR and between 25b8085 and 3e395c5.

📒 Files selected for processing (1)
  • docs/notion-task-triager-instructions.md

Comment on lines +20 to +33
### 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 |

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Document the counterparty limitation.

The required properties table does not mention that the counterparty field will be auto-populated as 'Unknown' when disputes are created from Notion legal tasks. Users may expect this to be extracted from Notion task properties (e.g., from the Description or a dedicated field), but context snippet 2 (src/lib/dispute-sync.ts:123) shows it is hardcoded. Consider adding a row or note explaining this limitation so users understand they may need to update the counterparty field manually in ChittyCommand after sync.

📝 Suggested addition to the table

Add a note below the table:

 | **Due Date** | Set if there's an explicit deadline | Court dates, response deadlines, filing windows |
+
+> **Note**: The `Counterparty` field is automatically set to "Unknown" during sync. You can update it manually in ChittyCommand after the dispute is created.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### 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 |
### 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 |
> **Note**: The `Counterparty` field is automatically set to "Unknown" during sync. You can update it manually in ChittyCommand after the dispute is created.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/notion-task-triager-instructions.md` around lines 20 - 33, The docs fail
to state that the counterparty property is auto-populated as "Unknown" for
disputes created from Notion; add a new row or a short note directly below the
Required Properties table stating: "counterparty — defaults to 'Unknown' for
Notion-imported disputes; update manually in ChittyCommand if known." Reference
the code that enforces this default (the assignment setting counterparty =
"Unknown" in the dispute-sync logic) so readers know why the field may need
manual correction after sync.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant