← 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
A db/migrations/0004_claims.sqlA lib/claim-strategist.jsA routes/claims.jsM server.jsA tests/claims.test.js
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 →