← back to AbramsOS
tick 11: rights_rule_snapshot + 8-rule starter corpus + cited drafts
fffc3d8a8f794c39a0731656ad214b8642d1d71b · 2026-05-10 08:04:00 -0700 · Steve
- db/migrations/0005_rights_rules.sql: rights_rule_snapshot (versioned, jurisdictional)
- scripts/seed-rights-rules.js: 8 hand-curated rules
· 15 USC §1666i / Reg Z claims-and-defenses (card disputes ≥$50)
· 12 CFR §1026.13 billing-error rights (60d window)
· Magnuson-Moss federal warranty (15 USC §2301-2312)
· 16 CFR §700 FTC pre-sale warranty disclosure
· FTC money-back guarantee guides
· 14 CFR §259.5 / DOT 2024 airline refund rule
· EU 261/2004 standard compensation
· CPSC recall remedy entitlement
- lib/claim-strategist.js: pickCitedRules() matches reason_code + routing,
threads citation_block into both LLM prompt and template fallback;
fills claim_case.cited_rules_jsonb with citation+source_url+title
- views/claim-detail.ejs: renders 'Cited rules' panel with source links
- 6 new tests (matching, limits, unknown-reason fallthrough)
- 42/43 green; 15 PG tables now
Files touched
A db/migrations/0005_rights_rules.sqlM lib/claim-strategist.jsA scripts/seed-rights-rules.jsA tests/rights-rules.test.jsM views/claim-detail.ejs
Diff
commit fffc3d8a8f794c39a0731656ad214b8642d1d71b
Author: Steve <steve@designerwallcoverings.com>
Date: Sun May 10 08:04:00 2026 -0700
tick 11: rights_rule_snapshot + 8-rule starter corpus + cited drafts
- db/migrations/0005_rights_rules.sql: rights_rule_snapshot (versioned, jurisdictional)
- scripts/seed-rights-rules.js: 8 hand-curated rules
· 15 USC §1666i / Reg Z claims-and-defenses (card disputes ≥$50)
· 12 CFR §1026.13 billing-error rights (60d window)
· Magnuson-Moss federal warranty (15 USC §2301-2312)
· 16 CFR §700 FTC pre-sale warranty disclosure
· FTC money-back guarantee guides
· 14 CFR §259.5 / DOT 2024 airline refund rule
· EU 261/2004 standard compensation
· CPSC recall remedy entitlement
- lib/claim-strategist.js: pickCitedRules() matches reason_code + routing,
threads citation_block into both LLM prompt and template fallback;
fills claim_case.cited_rules_jsonb with citation+source_url+title
- views/claim-detail.ejs: renders 'Cited rules' panel with source links
- 6 new tests (matching, limits, unknown-reason fallthrough)
- 42/43 green; 15 PG tables now
---
db/migrations/0005_rights_rules.sql | 24 ++++++++
lib/claim-strategist.js | 47 +++++++++++++--
scripts/seed-rights-rules.js | 116 ++++++++++++++++++++++++++++++++++++
tests/rights-rules.test.js | 44 ++++++++++++++
views/claim-detail.ejs | 16 +++++
5 files changed, 242 insertions(+), 5 deletions(-)
diff --git a/db/migrations/0005_rights_rules.sql b/db/migrations/0005_rights_rules.sql
new file mode 100644
index 0000000..9beb3e8
--- /dev/null
+++ b/db/migrations/0005_rights_rules.sql
@@ -0,0 +1,24 @@
+-- 0005_rights_rules.sql — versioned policy/rule corpus
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS rights_rule_snapshot (
+ id text PRIMARY KEY,
+ domain text NOT NULL, -- 'card_dispute' | 'warranty' | 'airline' | 'returns' | 'medical' | 'recall'
+ jurisdiction text NOT NULL, -- 'US' | 'US-CA' | 'EU' | 'UK' | 'CA' | 'BR' | 'INTL' (ICAO)
+ citation text NOT NULL, -- '12 CFR §1026.12(c)' | 'FTC §700' | 'DOT 14 CFR §259.5' | 'EU 261/2004' | etc.
+ source_url text,
+ effective_date date,
+ superseded_at timestamptz, -- NULL while active
+ short_title text NOT NULL, -- one-line label
+ applies_when jsonb NOT NULL DEFAULT '{}'::jsonb, -- selector: { reason_codes: [...], min_amount: N, route_through: 'merchant', ... }
+ rule_summary text NOT NULL, -- one-paragraph plain-English statement
+ citation_block text, -- block to drop into a letter ("Per 12 CFR §1026.12(c)…")
+ created_at timestamptz NOT NULL DEFAULT now(),
+ metadata_jsonb jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX IF NOT EXISTS rights_rule_domain_idx ON rights_rule_snapshot (domain, jurisdiction) WHERE superseded_at IS NULL;
+CREATE INDEX IF NOT EXISTS rights_rule_applies_idx ON rights_rule_snapshot USING gin (applies_when);
+CREATE UNIQUE INDEX IF NOT EXISTS rights_rule_citation_active ON rights_rule_snapshot (citation) WHERE superseded_at IS NULL;
+
+COMMIT;
diff --git a/lib/claim-strategist.js b/lib/claim-strategist.js
index b133427..49487a3 100644
--- a/lib/claim-strategist.js
+++ b/lib/claim-strategist.js
@@ -30,7 +30,33 @@ function buildContext({ reminder, purchase, recall }) {
return lines.filter(Boolean).join('\n');
}
-async function draftLetter({ routing, claimType, contextText, desiredRemedy, llm = true, opts = {} }) {
+/**
+ * Look up active rights_rule_snapshot rows that match this draft. Returns up
+ * to 2 strongest matches (ordered by domain priority + amount-threshold fit).
+ */
+async function pickCitedRules({ reasonCode, routing, minAmount = 0, jurisdiction = 'US', limit = 2 }) {
+ // Match: applies_when.reason_codes contains reasonCode AND applies_when.routing contains routing.
+ const r = await db.query(
+ `SELECT id, citation, source_url, short_title, citation_block, applies_when
+ FROM rights_rule_snapshot
+ WHERE superseded_at IS NULL
+ AND (jurisdiction = $1 OR jurisdiction = 'INTL')
+ AND applies_when @> $2::jsonb
+ ORDER BY
+ (applies_when ? 'min_amount' AND ($3::numeric >= COALESCE((applies_when->>'min_amount')::numeric, 0))) DESC,
+ domain ASC
+ LIMIT $4`,
+ [jurisdiction, JSON.stringify({ reason_codes: [reasonCode], routing: [routing] }), minAmount, limit]
+ );
+ return r.rows;
+}
+
+async function draftLetter({ routing, claimType, contextText, desiredRemedy, citedRules = [], llm = true, opts = {} }) {
+ const ruleBlock = (citedRules || [])
+ .map(r => r.citation_block)
+ .filter(Boolean)
+ .join('\n\n');
+
const fallback = `To Whom It May Concern,
I am writing about the following:
@@ -39,7 +65,7 @@ ${contextText}
I am requesting: ${desiredRemedy}.
-Please respond within 14 business days. I have retained a copy of this letter.
+${ruleBlock ? ruleBlock + '\n\n' : ''}Please respond within 14 business days. I have retained a copy of this letter.
Sincerely,
[your name]`;
@@ -63,6 +89,7 @@ Routing: this letter goes to the ${routing}.
Claim type: ${claimType}.
Desired remedy: ${desiredRemedy}.
+${citedRules.length ? 'Cite these rules verbatim near the end of the body (one short paragraph each):\n' + citedRules.map(r => `- ${r.citation_block}`).join('\n') + '\n' : ''}
Facts:
${contextText}`;
@@ -105,23 +132,33 @@ async function fromReminder(reminderId, userId) {
}
const ctx = buildContext({ reminder: r, purchase, recall });
+
+ // Pick 1-2 cited rules from the corpus that match this reason_code + routing
+ const citedRules = await pickCitedRules({
+ reasonCode: r.reason_code,
+ routing: reasonCfg.routing,
+ minAmount: purchase?.total_amount || 0,
+ });
+
const letter = await draftLetter({
routing: reasonCfg.routing,
claimType: reasonCfg.claim_type,
contextText: ctx,
desiredRemedy: reasonCfg.desired,
+ citedRules,
});
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)`,
+ `INSERT INTO claim_case (id, user_id, asset_table, asset_id, claim_type, routing, desired_remedy, due_at, draft_subject, draft_body, cited_rules_jsonb, evidence_jsonb, metadata_jsonb)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
[
caseId, userId,
r.owner_table, r.owner_id,
reasonCfg.claim_type, reasonCfg.routing,
reasonCfg.desired, r.due_at,
letter.subject, letter.body,
+ JSON.stringify(citedRules.map(rule => ({ id: rule.id, citation: rule.citation, source_url: rule.source_url, short_title: rule.short_title }))),
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 }),
]
@@ -145,4 +182,4 @@ async function fromReminder(reminderId, userId) {
return { caseId, claim: { ...letter, claim_type: reasonCfg.claim_type, routing: reasonCfg.routing, desired_remedy: reasonCfg.desired } };
}
-module.exports = { fromReminder, draftLetter, buildContext, ROUTING_BY_REASON };
+module.exports = { fromReminder, draftLetter, buildContext, pickCitedRules, ROUTING_BY_REASON };
diff --git a/scripts/seed-rights-rules.js b/scripts/seed-rights-rules.js
new file mode 100755
index 0000000..63e7a9d
--- /dev/null
+++ b/scripts/seed-rights-rules.js
@@ -0,0 +1,116 @@
+#!/usr/bin/env node
+// Seed the rights_rule_snapshot table with a hand-curated starter corpus.
+// All rules are public statutory/regulatory references. Real ingestion of
+// the full CFR / FTC guidance / DOT rules is a future tick — this is enough
+// for the claim strategist to cite SOMETHING accurate in drafts.
+//
+// Idempotent: ON CONFLICT (citation) WHERE superseded_at IS NULL DO UPDATE.
+
+require('dotenv').config();
+const db = require('../lib/db');
+const { id } = require('../lib/ids');
+
+const RULES = [
+ {
+ domain: 'card_dispute', jurisdiction: 'US',
+ citation: '15 USC §1666i / 12 CFR §1026.12(c)',
+ source_url: 'https://www.consumerfinance.gov/rules-policy/regulations/1026/12/',
+ short_title: 'Credit-card claims-and-defenses (FCBA / Reg Z)',
+ rule_summary: 'A consumer using a credit card may assert against the card issuer all defenses (other than tort) that they could assert against the merchant, when the purchase was over $50 and either in the same state or within 100 miles of the cardholder\'s billing address. The right is in addition to the merchant chargeback process.',
+ citation_block: 'Under the federal Fair Credit Billing Act (15 U.S.C. §1666i) and Regulation Z (12 C.F.R. §1026.12(c)), I am asserting my claims-and-defenses rights against my card issuer for the disputed charge described above.',
+ applies_when: { reason_codes: ['returns_window_closing', 'merchant_no_response'], routing: ['issuer'], min_amount: 50 },
+ },
+ {
+ domain: 'card_dispute', jurisdiction: 'US',
+ citation: '15 USC §1666 / 12 CFR §1026.13',
+ source_url: 'https://www.consumerfinance.gov/rules-policy/regulations/1026/13/',
+ short_title: 'Billing-error rights (60-day written notice)',
+ rule_summary: 'A consumer must send a written billing-error notice within 60 days of the first statement showing the disputed charge. The issuer must acknowledge within 30 days and resolve within two billing cycles (max 90 days).',
+ citation_block: 'Pursuant to 12 C.F.R. §1026.13, I am providing written notice of a billing error within the required 60-day window. I request acknowledgment within 30 days and resolution within two billing cycles.',
+ applies_when: { reason_codes: ['returns_window_closing'], routing: ['issuer'] },
+ },
+ {
+ domain: 'warranty', jurisdiction: 'US',
+ citation: 'Magnuson-Moss Warranty Act / 15 USC §2301-2312',
+ source_url: 'https://www.ftc.gov/legal-library/browse/statutes/magnuson-moss-warranty-federal-trade-commission-improvement-act',
+ short_title: 'Magnuson-Moss federal warranty law',
+ rule_summary: 'Written warranties on consumer products costing over $15 must be conspicuously labeled "full" or "limited", and the warrantor must remedy defects within a reasonable time at no charge. After a reasonable number of attempts, the consumer may demand refund or replacement.',
+ citation_block: 'Under the Magnuson-Moss Warranty Act (15 U.S.C. §§2301-2312), the warrantor is obligated to remedy defects within a reasonable time at no charge. After repeated unsuccessful attempts, I am entitled to refund or replacement.',
+ applies_when: { reason_codes: ['warranty_expiry'], routing: ['manufacturer'] },
+ },
+ {
+ domain: 'warranty', jurisdiction: 'US',
+ citation: '16 CFR §700 (FTC warranty advertising guides)',
+ source_url: 'https://www.ftc.gov/legal-library/browse/rules/disclosure-written-consumer-product-warranty-terms-conditions',
+ short_title: 'FTC pre-sale warranty disclosure rule',
+ rule_summary: 'Sellers of consumer products with written warranties must make warranty terms available for the consumer to review BEFORE the sale. Vague claims like "limited lifetime warranty" must clearly state covered components, duration, and remedy.',
+ citation_block: 'Per 16 C.F.R. §700, the seller is required to make warranty terms available for pre-sale review. The advertised warranty terms govern, and any ambiguity is construed against the warrantor.',
+ applies_when: { reason_codes: ['warranty_expiry'], routing: ['merchant', 'manufacturer'] },
+ },
+ {
+ domain: 'returns', jurisdiction: 'US',
+ citation: 'FTC Money-Back Guarantee guides',
+ source_url: 'https://www.ftc.gov/business-guidance/resources/advertising-faqs-guide-small-business',
+ short_title: 'FTC truth-in-advertising / money-back guarantee',
+ rule_summary: 'When a seller advertises a "money-back guarantee", "satisfaction guaranteed", or similar promise, the FTC requires the seller to honor the promise on the consumer\'s request without imposing material conditions not disclosed in the offer.',
+ citation_block: 'Per the FTC\'s guides on advertising and money-back guarantees, the merchant\'s public guarantee must be honored on request without undisclosed conditions.',
+ applies_when: { reason_codes: ['returns_window_closing'], routing: ['merchant'] },
+ },
+ {
+ domain: 'airline', jurisdiction: 'US',
+ citation: '14 CFR §259.5 / DOT refund rule',
+ source_url: 'https://www.transportation.gov/airconsumer/refunds',
+ short_title: 'DOT — airline refunds for cancelled / significantly changed flights',
+ rule_summary: 'A US-flagged carrier or any carrier serving the US must give a prompt cash refund (not just a voucher) when the airline cancels a flight or makes a significant schedule change and the passenger declines the alternative. Codified by the DOT 2024 final rule.',
+ citation_block: 'Under U.S. Department of Transportation 14 C.F.R. §259.5 and the 2024 refund rule, I am entitled to a prompt cash refund — not a voucher — for the cancelled/significantly-changed flight described above.',
+ applies_when: { reason_codes: ['airline_cancellation', 'airline_significant_change'], routing: ['merchant'] },
+ },
+ {
+ domain: 'airline', jurisdiction: 'EU',
+ citation: 'Regulation (EC) 261/2004',
+ source_url: 'https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32004R0261',
+ short_title: 'EU 261 — standard compensation for delays/cancellations',
+ rule_summary: 'For flights departing from an EU airport (or arriving in EU on an EU carrier), passengers are entitled to fixed compensation (€250 / €400 / €600 by distance) when delayed ≥3 hours, cancelled within 14 days, or denied boarding, unless the carrier proves extraordinary circumstances.',
+ citation_block: 'Under Regulation (EC) 261/2004, I am entitled to standard compensation of €[amount] for the qualifying delay/cancellation/denied-boarding event described above. The carrier bears the burden of proving extraordinary circumstances.',
+ applies_when: { reason_codes: ['airline_cancellation', 'airline_significant_change'], routing: ['merchant'], jurisdiction_match: 'EU' },
+ },
+ {
+ domain: 'recall', jurisdiction: 'US',
+ citation: 'CPSC Recall — manufacturer remedy obligation',
+ source_url: 'https://www.cpsc.gov/Recalls',
+ short_title: 'CPSC recall remedy entitlement',
+ rule_summary: 'For a CPSC-published recall, the firm has agreed with the U.S. Consumer Product Safety Commission to provide a specific remedy (refund, repair, or replacement) at no cost to consumers. The remedy is enforceable under the firm\'s recall agreement.',
+ citation_block: 'Per the CPSC recall notice [ID] published on [date], I am entitled to the agreed remedy ([refund/repair/replacement]) at no cost to me.',
+ applies_when: { reason_codes: ['recall_action_due'], routing: ['manufacturer'] },
+ },
+];
+
+async function main() {
+ let inserted = 0, updated = 0;
+ for (const r of RULES) {
+ const result = await db.query(
+ `INSERT INTO rights_rule_snapshot
+ (id, domain, jurisdiction, citation, source_url, short_title, rule_summary, citation_block, applies_when)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+ ON CONFLICT (citation) WHERE superseded_at IS NULL DO UPDATE SET
+ domain = EXCLUDED.domain,
+ jurisdiction = EXCLUDED.jurisdiction,
+ source_url = EXCLUDED.source_url,
+ short_title = EXCLUDED.short_title,
+ rule_summary = EXCLUDED.rule_summary,
+ citation_block = EXCLUDED.citation_block,
+ applies_when = EXCLUDED.applies_when
+ RETURNING id, (xmax = 0) AS was_inserted`,
+ [id('document'), r.domain, r.jurisdiction, r.citation, r.source_url, r.short_title, r.rule_summary, r.citation_block, JSON.stringify(r.applies_when)]
+ );
+ if (result.rows[0].was_inserted) inserted += 1; else updated += 1;
+ }
+ console.log(`[seed-rights-rules] inserted=${inserted} updated=${updated} total=${RULES.length}`);
+ await db.pool.end();
+}
+
+if (require.main === module) {
+ main().catch((e) => { console.error(e); process.exit(1); });
+}
+
+module.exports = { RULES };
diff --git a/tests/rights-rules.test.js b/tests/rights-rules.test.js
new file mode 100644
index 0000000..4573950
--- /dev/null
+++ b/tests/rights-rules.test.js
@@ -0,0 +1,44 @@
+// Verifies pickCitedRules selects the right rule for each reason+routing combo.
+
+const test = require('node:test');
+const assert = require('node:assert');
+
+require('dotenv').config();
+const db = require('../lib/db');
+const strategist = require('../lib/claim-strategist');
+
+test.after(async () => { await db.pool.end(); });
+
+test('returns_window_closing → merchant returns FTC money-back guide', async () => {
+ const rules = await strategist.pickCitedRules({ reasonCode: 'returns_window_closing', routing: 'merchant', minAmount: 100 });
+ assert.ok(rules.length >= 1, 'should find at least one rule');
+ assert.ok(rules.some(r => /money-back|FTC/i.test(r.short_title) || /FTC/.test(r.citation)), 'should include the FTC guide');
+});
+
+test('returns_window_closing → issuer returns Reg Z claims-and-defenses', async () => {
+ const rules = await strategist.pickCitedRules({ reasonCode: 'returns_window_closing', routing: 'issuer', minAmount: 100 });
+ assert.ok(rules.length >= 1);
+ assert.ok(rules.some(r => /1666i|claims-and-defenses/i.test(r.citation) || /Reg Z|FCBA/i.test(r.short_title)), 'should include FCBA/Reg Z');
+});
+
+test('warranty_expiry → manufacturer returns Magnuson-Moss', async () => {
+ const rules = await strategist.pickCitedRules({ reasonCode: 'warranty_expiry', routing: 'manufacturer' });
+ assert.ok(rules.length >= 1);
+ assert.ok(rules.some(r => /Magnuson-Moss|2301/i.test(r.citation) || /Magnuson/i.test(r.short_title)));
+});
+
+test('recall_action_due → manufacturer returns CPSC remedy', async () => {
+ const rules = await strategist.pickCitedRules({ reasonCode: 'recall_action_due', routing: 'manufacturer' });
+ assert.ok(rules.length >= 1);
+ assert.ok(rules.some(r => /CPSC/.test(r.citation)));
+});
+
+test('unknown reason returns 0 rules', async () => {
+ const rules = await strategist.pickCitedRules({ reasonCode: 'unknown_reason_xyz', routing: 'merchant' });
+ assert.strictEqual(rules.length, 0);
+});
+
+test('limit caps results', async () => {
+ const rules = await strategist.pickCitedRules({ reasonCode: 'returns_window_closing', routing: 'merchant', limit: 1 });
+ assert.ok(rules.length <= 1);
+});
diff --git a/views/claim-detail.ejs b/views/claim-detail.ejs
index 0cf43c6..9c0d2e4 100644
--- a/views/claim-detail.ejs
+++ b/views/claim-detail.ejs
@@ -23,6 +23,22 @@
<pre style="white-space:pre-wrap;font-family:Georgia,'Times New Roman',serif;font-size:14px;line-height:1.55;color:var(--text);background:rgba(0,0,0,0.18);padding:18px;border-radius:10px;border:1px solid var(--border)"><%= claim.draft_body %></pre>
</section>
+<% if (claim.cited_rules_jsonb && claim.cited_rules_jsonb.length) { %>
+ <section class="glass" style="padding:22px 24px">
+ <h2 style="margin:0 0 12px 0;font-size:16px">Cited rules</h2>
+ <ul style="margin:0;padding-left:18px">
+ <% claim.cited_rules_jsonb.forEach(rule => { %>
+ <li style="font-size:13px;color:var(--text-dim);margin-bottom:8px">
+ <strong style="color:var(--text)"><%= rule.citation %></strong> — <%= rule.short_title %>
+ <% if (rule.source_url) { %>
+ <a href="<%= rule.source_url %>" target="_blank" rel="noopener" style="margin-left:8px;font-size:11px">source ↗</a>
+ <% } %>
+ </li>
+ <% }) %>
+ </ul>
+ </section>
+<% } %>
+
<% if (claim.evidence_jsonb && claim.evidence_jsonb.length) { %>
<section class="glass" style="padding:22px 24px">
<h2 style="margin:0 0 12px 0;font-size:16px">Evidence index</h2>
← ee6a1eb tick 10: /audit page (read-only viewer for audit_log + auth_
·
back to AbramsOS
·
tick 12: service_commitment + merchant policy extractor 00b6568 →