[object Object]

← back to AbramsOS

tick 6: claim_case + action_queue + claim strategist (drafts only)

7294ca3042cdb1f79aa6181faed50820e72527c5 · 2026-05-10 00:38:34 -0700 · Steve

- db/migrations/0004_claims.sql: claim_case (state machine: draft|sent|resolved)
  + action_queue (approval_level: auto|user_required, state: pending|approved|executed)
- lib/claim-strategist.js: deterministic routing by reason_code
  · returns_window_closing → refund / merchant
  · warranty_expiry         → repair / manufacturer
  · recall_action_due       → recall_remedy / manufacturer
  Letter drafted via local Ollama qwen3:14b; falls through to template if LLM down.
  Every claim_case spawns ONE user_required action_queue row — never auto-executes.
- routes/claims.js: GET /api/claims, /:id; POST /from-reminder/:id; /:caseId/actions/:actionId/approve
- 2 new tests with mocked-down Ollama proving the template-fallback path
- 30/31 green (1 skip — pdf-parse fixture)

Files touched

Diff

commit 7294ca3042cdb1f79aa6181faed50820e72527c5
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun May 10 00:38:34 2026 -0700

    tick 6: claim_case + action_queue + claim strategist (drafts only)
    
    - db/migrations/0004_claims.sql: claim_case (state machine: draft|sent|resolved)
      + action_queue (approval_level: auto|user_required, state: pending|approved|executed)
    - lib/claim-strategist.js: deterministic routing by reason_code
      · returns_window_closing → refund / merchant
      · warranty_expiry         → repair / manufacturer
      · recall_action_due       → recall_remedy / manufacturer
      Letter drafted via local Ollama qwen3:14b; falls through to template if LLM down.
      Every claim_case spawns ONE user_required action_queue row — never auto-executes.
    - routes/claims.js: GET /api/claims, /:id; POST /from-reminder/:id; /:caseId/actions/:actionId/approve
    - 2 new tests with mocked-down Ollama proving the template-fallback path
    - 30/31 green (1 skip — pdf-parse fixture)
---
 db/migrations/0004_claims.sql |  43 ++++++++++++
 lib/claim-strategist.js       | 148 ++++++++++++++++++++++++++++++++++++++++++
 routes/claims.js              |  53 +++++++++++++++
 server.js                     |   2 +
 tests/claims.test.js          |  88 +++++++++++++++++++++++++
 5 files changed, 334 insertions(+)

diff --git a/db/migrations/0004_claims.sql b/db/migrations/0004_claims.sql
new file mode 100644
index 0000000..259d25a
--- /dev/null
+++ b/db/migrations/0004_claims.sql
@@ -0,0 +1,43 @@
+-- 0004_claims.sql — claim_case + action_queue tables (Phase 4 starter)
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS claim_case (
+  id              text PRIMARY KEY,
+  user_id         text NOT NULL REFERENCES user_account(id) ON DELETE CASCADE,
+  asset_table     text,                       -- 'purchase', 'recall_match'
+  asset_id        text,
+  claim_type      text NOT NULL,              -- refund | replace | repair | recall_remedy | dispute
+  routing         text NOT NULL,              -- merchant | manufacturer | issuer | regulator
+  jurisdiction    text DEFAULT 'US-CA',
+  state           text NOT NULL DEFAULT 'draft',  -- draft | sent | resolved | abandoned
+  desired_remedy  text,
+  due_at          timestamptz,
+  draft_subject   text,
+  draft_body      text,
+  cited_rules_jsonb jsonb NOT NULL DEFAULT '[]'::jsonb,
+  evidence_jsonb  jsonb NOT NULL DEFAULT '[]'::jsonb,
+  metadata_jsonb  jsonb NOT NULL DEFAULT '{}'::jsonb,
+  created_at      timestamptz NOT NULL DEFAULT now(),
+  updated_at      timestamptz NOT NULL DEFAULT now(),
+  resolved_at     timestamptz
+);
+CREATE INDEX IF NOT EXISTS claim_case_user_state_idx ON claim_case (user_id, state, due_at);
+CREATE INDEX IF NOT EXISTS claim_case_asset_idx ON claim_case (asset_table, asset_id);
+
+CREATE TABLE IF NOT EXISTS action_queue (
+  id              text PRIMARY KEY,
+  case_id         text NOT NULL REFERENCES claim_case(id) ON DELETE CASCADE,
+  action_type     text NOT NULL,              -- compose_letter | send_email | file_dispute | open_calendar | request_signature
+  approval_level  text NOT NULL,              -- auto | user_required
+  scheduled_at    timestamptz,
+  executed_at     timestamptz,
+  state           text NOT NULL DEFAULT 'pending',  -- pending | approved | executed | denied | failed
+  payload_jsonb   jsonb NOT NULL DEFAULT '{}'::jsonb,
+  result_jsonb    jsonb,
+  created_at      timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS action_queue_case_idx ON action_queue (case_id, created_at);
+CREATE INDEX IF NOT EXISTS action_queue_state_idx ON action_queue (state, scheduled_at);
+
+COMMIT;
diff --git a/lib/claim-strategist.js b/lib/claim-strategist.js
new file mode 100644
index 0000000..b133427
--- /dev/null
+++ b/lib/claim-strategist.js
@@ -0,0 +1,148 @@
+// Claim Strategist — picks the best next action for an asset + reminder/recall pair.
+// Honors AGENTS.md hard rules: drafts only, no send. Caller approves separately.
+
+const db = require('./db');
+const audit = require('./audit');
+const ollama = require('./ollama');
+const { id } = require('./ids');
+
+// Deterministic routing by reason_code; LLM only drafts the prose.
+const ROUTING_BY_REASON = {
+  returns_window_closing: { claim_type: 'refund',         routing: 'merchant',     desired: 'Refund or store credit' },
+  warranty_expiry:        { claim_type: 'repair',         routing: 'manufacturer', desired: 'Repair or replacement under warranty' },
+  recall_action_due:      { claim_type: 'recall_remedy',  routing: 'manufacturer', desired: 'Free remedy per CPSC notice (refund/repair/replace)' },
+};
+
+function buildContext({ reminder, purchase, recall }) {
+  const lines = [];
+  if (purchase) {
+    lines.push(`Purchase: ${purchase.merchant_name}`);
+    if (purchase.order_number) lines.push(`Order #: ${purchase.order_number}`);
+    lines.push(`Date: ${new Date(purchase.purchase_date).toDateString()}`);
+    if (purchase.total_amount) lines.push(`Amount: ${purchase.currency || 'USD'} ${purchase.total_amount}`);
+  }
+  if (recall) {
+    lines.push(`Recall title: ${recall.title || ''}`);
+    lines.push(`Hazard: ${recall.hazard || ''}`);
+    lines.push(`Remedy offered: ${recall.remedy || ''}`);
+    lines.push(`Source: ${recall.url || 'CPSC'}`);
+  }
+  return lines.filter(Boolean).join('\n');
+}
+
+async function draftLetter({ routing, claimType, contextText, desiredRemedy, llm = true, opts = {} }) {
+  const fallback = `To Whom It May Concern,
+
+I am writing about the following:
+
+${contextText}
+
+I am requesting: ${desiredRemedy}.
+
+Please respond within 14 business days. I have retained a copy of this letter.
+
+Sincerely,
+[your name]`;
+
+  if (!llm) return { subject: `${claimType} request — ${routing}`, body: fallback, source: 'template' };
+
+  const prompt = `Draft a polite, firm consumer claim letter. Output ONLY a JSON object — no prose, no fences.
+
+Schema: { "subject": string, "body": string }
+
+The body must:
+- be under 250 words
+- use plain English
+- reference the specific facts below
+- request the desired remedy clearly
+- ask for a written response within 14 business days
+- close with "Sincerely, [your name]"
+- NOT cite specific case law or pretend to be a lawyer
+
+Routing: this letter goes to the ${routing}.
+Claim type: ${claimType}.
+Desired remedy: ${desiredRemedy}.
+
+Facts:
+${contextText}`;
+
+  try {
+    const j = await ollama.generateJson(prompt, { timeoutMs: opts.timeoutMs || 30_000, model: opts.model });
+    return { subject: j.subject || `${claimType} request — ${routing}`, body: j.body || fallback, source: 'llm' };
+  } catch (err) {
+    return { subject: `${claimType} request — ${routing}`, body: fallback, source: 'template_fallback', llmError: err.message };
+  }
+}
+
+/**
+ * Build a claim_case row from a calendar_reminder.
+ * Returns { caseId, claim }.
+ */
+async function fromReminder(reminderId, userId) {
+  const rem = await db.query(`SELECT * FROM calendar_reminder WHERE id = $1 AND user_id = $2`, [reminderId, userId]);
+  if (!rem.rows.length) throw new Error('reminder not found');
+  const r = rem.rows[0];
+  const reasonCfg = ROUTING_BY_REASON[r.reason_code];
+  if (!reasonCfg) throw new Error(`no claim routing for reason_code=${r.reason_code}`);
+
+  // Resolve the asset behind the reminder
+  let purchase = null, recall = null;
+  if (r.owner_table === 'purchase') {
+    const p = await db.query(`SELECT * FROM purchase WHERE id = $1`, [r.owner_id]);
+    purchase = p.rows[0] || null;
+  } else if (r.owner_table === 'recall_match') {
+    const m = await db.query(
+      `SELECT rm.*, re.title, re.hazard, re.remedy, re.url
+         FROM recall_match rm JOIN recall_event re ON re.id = rm.recall_id
+        WHERE rm.id = $1`,
+      [r.owner_id]
+    );
+    if (m.rows[0]) {
+      recall = { title: m.rows[0].title, hazard: m.rows[0].hazard, remedy: m.rows[0].remedy, url: m.rows[0].url };
+      const p = await db.query(`SELECT * FROM purchase WHERE id = $1`, [m.rows[0].asset_id]);
+      purchase = p.rows[0] || null;
+    }
+  }
+
+  const ctx = buildContext({ reminder: r, purchase, recall });
+  const letter = await draftLetter({
+    routing: reasonCfg.routing,
+    claimType: reasonCfg.claim_type,
+    contextText: ctx,
+    desiredRemedy: reasonCfg.desired,
+  });
+
+  const caseId = id('purchase'); // reuse ulid prefix
+  await db.query(
+    `INSERT INTO claim_case (id, user_id, asset_table, asset_id, claim_type, routing, desired_remedy, due_at, draft_subject, draft_body, evidence_jsonb, metadata_jsonb)
+     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
+    [
+      caseId, userId,
+      r.owner_table, r.owner_id,
+      reasonCfg.claim_type, reasonCfg.routing,
+      reasonCfg.desired, r.due_at,
+      letter.subject, letter.body,
+      JSON.stringify([{ kind: 'reminder', id: reminderId, due_at: r.due_at }]),
+      JSON.stringify({ from_reminder: reminderId, draft_source: letter.source, llm_error: letter.llmError || null }),
+    ]
+  );
+
+  // Queue a "user_required" action: the user must approve before we send anything
+  await db.query(
+    `INSERT INTO action_queue (id, case_id, action_type, approval_level, payload_jsonb)
+     VALUES ($1, $2, 'send_email', 'user_required', $3)`,
+    [id('purchase'), caseId, JSON.stringify({ to_role: reasonCfg.routing, subject: letter.subject })]
+  );
+
+  await audit.log({
+    actorType: 'system',
+    objectType: 'claim_case',
+    objectId: caseId,
+    eventType: 'claim_drafted',
+    metadata: { from_reminder: reminderId, routing: reasonCfg.routing, claim_type: reasonCfg.claim_type, source: letter.source },
+  });
+
+  return { caseId, claim: { ...letter, claim_type: reasonCfg.claim_type, routing: reasonCfg.routing, desired_remedy: reasonCfg.desired } };
+}
+
+module.exports = { fromReminder, draftLetter, buildContext, ROUTING_BY_REASON };
diff --git a/routes/claims.js b/routes/claims.js
new file mode 100644
index 0000000..0322945
--- /dev/null
+++ b/routes/claims.js
@@ -0,0 +1,53 @@
+const express = require('express');
+const db = require('../lib/db');
+const audit = require('../lib/audit');
+const strategist = require('../lib/claim-strategist');
+
+const router = express.Router();
+const DEV_USER_ID = 'user_steve';
+
+router.get('/api/claims', async (_req, res) => {
+  const r = await db.query(
+    `SELECT id, claim_type, routing, state, draft_subject, due_at, created_at, asset_table, asset_id
+       FROM claim_case WHERE user_id = $1 ORDER BY created_at DESC LIMIT 200`,
+    [DEV_USER_ID]
+  );
+  res.json(r.rows);
+});
+
+router.get('/api/claims/:id', async (req, res) => {
+  const r = await db.query(`SELECT * FROM claim_case WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
+  if (!r.rows.length) return res.status(404).json({ error: 'claim not found' });
+  const actions = await db.query(`SELECT * FROM action_queue WHERE case_id = $1 ORDER BY created_at`, [req.params.id]);
+  res.json({ ...r.rows[0], actions: actions.rows });
+});
+
+router.post('/api/claims/from-reminder/:id', async (req, res) => {
+  try {
+    const { caseId, claim } = await strategist.fromReminder(req.params.id, DEV_USER_ID);
+    res.json({ ok: true, case_id: caseId, claim });
+  } catch (err) {
+    res.status(400).json({ error: err.message });
+  }
+});
+
+// Approve an action — but DON'T execute. Steve still has to physically click "send" elsewhere.
+// This just flips the gate; sending happens in a future tick when we wire George email.
+router.post('/api/claims/:caseId/actions/:actionId/approve', async (req, res) => {
+  const { caseId, actionId } = req.params;
+  await db.query(
+    `UPDATE action_queue SET state = 'approved' WHERE id = $1 AND case_id = $2 AND state = 'pending'`,
+    [actionId, caseId]
+  );
+  await audit.log({
+    actorType: 'user',
+    actorId: DEV_USER_ID,
+    objectType: 'action_queue',
+    objectId: actionId,
+    eventType: 'action_approved',
+    metadata: { case_id: caseId },
+  });
+  res.json({ ok: true });
+});
+
+module.exports = router;
diff --git a/server.js b/server.js
index 0d39771..4a6fa27 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,7 @@ const importRouter = require('./routes/import');
 const plaidRouter = require('./routes/plaid');
 const documentsRouter = require('./routes/documents');
 const remindersRouter = require('./routes/reminders');
+const claimsRouter = require('./routes/claims');
 
 const app = express();
 const PORT = parseInt(process.env.PORT || '9931', 10);
@@ -57,6 +58,7 @@ app.use(purchases);                 // /purchases, /api/purchases
 app.use(plaidRouter);               // /api/plaid/*
 app.use(documentsRouter);           // /api/documents, /api/documents/:id/parse
 app.use(remindersRouter);           // /api/reminders/upcoming, regenerate, dismiss
+app.use(claimsRouter);              // /api/claims, /api/claims/from-reminder/:id
 
 // Step-up-required routes (must re-verify TOTP within 60s)
 app.use('/import', requireStepUp, importRouter);
diff --git a/tests/claims.test.js b/tests/claims.test.js
new file mode 100644
index 0000000..39885d5
--- /dev/null
+++ b/tests/claims.test.js
@@ -0,0 +1,88 @@
+// Claims engine: reminder → claim_case + queued action_queue row.
+// Mocks ollama with template fallback (so no network).
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+require('dotenv').config();
+
+// Inject ollama mock that always throws → strategist falls through to template
+const ollamaPath = require.resolve('../lib/ollama');
+const claimPath = require.resolve('../lib/claim-strategist');
+require.cache[ollamaPath] = { id: ollamaPath, filename: ollamaPath, loaded: true, exports: {
+  generateJson: async () => { throw new Error('mock-ollama-down'); },
+  generate: async () => '',
+  reachable: async () => false,
+  DEFAULT_BASE: 'http://stub', DEFAULT_MODEL: 'stub',
+}};
+delete require.cache[claimPath];
+
+const db = require('../lib/db');
+const strategist = require('../lib/claim-strategist');
+const TEST_USER_ID = 'user_steve';
+
+const REMINDER_ID = 'test_reminder_claim';
+const PURCHASE_ID = 'test_pur_claim';
+
+test.before(async () => {
+  // Clean any prior test data
+  await db.query(`DELETE FROM action_queue WHERE case_id IN (SELECT id FROM claim_case WHERE user_id = $1 AND metadata_jsonb->>'from_reminder' = $2)`, [TEST_USER_ID, REMINDER_ID]);
+  await db.query(`DELETE FROM claim_case WHERE user_id = $1 AND metadata_jsonb->>'from_reminder' = $2`, [TEST_USER_ID, REMINDER_ID]);
+  await db.query(`DELETE FROM calendar_reminder WHERE id = $1`, [REMINDER_ID]);
+  await db.query(`DELETE FROM purchase WHERE id = $1`, [PURCHASE_ID]);
+
+  // Seed: a purchase + a returns_window_closing reminder pointing at it
+  await db.query(
+    `INSERT INTO purchase (id, user_id, merchant_name, order_number, purchase_date, total_amount, currency, confidence)
+     VALUES ($1, $2, 'TestMart', 'TM-99999', $3, 49.99, 'USD', 0.92)`,
+    [PURCHASE_ID, TEST_USER_ID, new Date(Date.now() - 25 * 86400e3)]
+  );
+  await db.query(
+    `INSERT INTO calendar_reminder (id, user_id, owner_table, owner_id, due_at, reason_code, title, body)
+     VALUES ($1, $2, 'purchase', $3, $4, 'returns_window_closing', 'TestMart return window closes in 5d', 'Body')`,
+    [REMINDER_ID, TEST_USER_ID, PURCHASE_ID, new Date(Date.now() + 5 * 86400e3)]
+  );
+});
+
+test.after(async () => {
+  await db.query(`DELETE FROM action_queue WHERE case_id IN (SELECT id FROM claim_case WHERE metadata_jsonb->>'from_reminder' = $1)`, [REMINDER_ID]);
+  await db.query(`DELETE FROM claim_case WHERE metadata_jsonb->>'from_reminder' = $1`, [REMINDER_ID]);
+  await db.query(`DELETE FROM calendar_reminder WHERE id = $1`, [REMINDER_ID]);
+  await db.query(`DELETE FROM purchase WHERE id = $1`, [PURCHASE_ID]);
+  await db.pool.end();
+});
+
+test('fromReminder builds a draft claim_case + queues a user_required action', async () => {
+  const r = await strategist.fromReminder(REMINDER_ID, TEST_USER_ID);
+  assert.ok(r.caseId, 'case id returned');
+  assert.strictEqual(r.claim.routing, 'merchant');
+  assert.strictEqual(r.claim.claim_type, 'refund');
+  assert.match(r.claim.body, /TestMart/);
+  assert.match(r.claim.body, /TM-99999/);
+
+  // claim_case row exists
+  const c = await db.query(`SELECT * FROM claim_case WHERE id = $1`, [r.caseId]);
+  assert.strictEqual(c.rows.length, 1);
+  assert.strictEqual(c.rows[0].state, 'draft');
+  assert.strictEqual(c.rows[0].asset_table, 'purchase');
+  assert.strictEqual(c.rows[0].asset_id, PURCHASE_ID);
+
+  // action_queue row exists, gated user_required, NOT executed
+  const a = await db.query(`SELECT * FROM action_queue WHERE case_id = $1`, [r.caseId]);
+  assert.strictEqual(a.rows.length, 1);
+  assert.strictEqual(a.rows[0].action_type, 'send_email');
+  assert.strictEqual(a.rows[0].approval_level, 'user_required');
+  assert.strictEqual(a.rows[0].state, 'pending');
+  assert.strictEqual(a.rows[0].executed_at, null);
+});
+
+test('letter body uses template fallback when LLM is down', async () => {
+  // Find the case we just created
+  const c = await db.query(
+    `SELECT draft_body, metadata_jsonb FROM claim_case WHERE metadata_jsonb->>'from_reminder' = $1`,
+    [REMINDER_ID]
+  );
+  assert.ok(c.rows.length);
+  assert.match(c.rows[0].draft_body, /To Whom It May Concern/);
+  assert.ok(['template_fallback', 'template'].includes(c.rows[0].metadata_jsonb.draft_source));
+});

← b6f84e0 tick 5: calendar_reminder table + reminder engine  ·  back to AbramsOS  ·  tick 7: /claims dashboard UI + claim detail with draft previ 1f60c67 →