diff --git a/api/health.js b/api/health.js
index b37f13177a..fda34f6cc2 100644
--- a/api/health.js
+++ b/api/health.js
@@ -120,6 +120,7 @@ const STANDALONE_KEYS = {
simulationPackageLatest: 'forecast:simulation-package:latest',
simulationOutcomeLatest: 'forecast:simulation-outcome:latest',
newsThreatSummary: 'news:threat:summary:v1',
+ regulatoryActions: 'regulatory:actions:v1',
};
const SEED_META = {
@@ -180,6 +181,7 @@ const SEED_META = {
blsSeries: { key: 'seed-meta:economic:bls-series', maxStaleMin: 2880 }, // daily seed; 2880min = 48h = 2x interval
sanctionsPressure: { key: 'seed-meta:sanctions:pressure', maxStaleMin: 720 },
crossSourceSignals: { key: 'seed-meta:intelligence:cross-source-signals', maxStaleMin: 30 }, // 15min cron; 30min = 2x interval
+ regulatoryActions: { key: 'seed-meta:regulatory:actions', maxStaleMin: 240 },
sanctionsEntities: { key: 'seed-meta:sanctions:entities', maxStaleMin: 1440 }, // 12h cron; 1440min = 24h = 2x interval
radiationWatch: { key: 'seed-meta:radiation:observations', maxStaleMin: 30 },
groceryBasket: { key: 'seed-meta:economic:grocery-basket', maxStaleMin: 10080 }, // weekly seed; 10080 = 7 days
diff --git a/scripts/railway-set-watch-paths.mjs b/scripts/railway-set-watch-paths.mjs
index c5b2d38e0b..2866b77588 100644
--- a/scripts/railway-set-watch-paths.mjs
+++ b/scripts/railway-set-watch-paths.mjs
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
- * Sets watchPatterns and validates startCommand on all Railway seed services.
+ * Sets watchPatterns, validates startCommand, and syncs cronSchedule on Railway seed services.
*
* All seed services use rootDirectory="scripts", so the correct startCommand
* is `node seed-.mjs` (NOT `node scripts/seed-.mjs` — that path
@@ -22,6 +22,10 @@ const DRY_RUN = process.argv.includes('--dry-run');
const PROJECT_ID = '29419572-0b0d-437f-8e71-4fa68daf514f';
const ENV_ID = '91a05726-0b83-4d44-a33e-6aec94e58780';
const API = 'https://backboard.railway.app/graphql/v2';
+const REQUIRED_SEED_SERVICES = new Set(['seed-regulatory-actions']);
+const EXPECTED_CRON_SCHEDULES = new Map([
+ ['seed-regulatory-actions', '0 */2 * * *'],
+]);
// Seeds that use loadSharedConfig (depend on scripts/shared/*.json)
const USES_SHARED_CONFIG = new Set([
@@ -50,6 +54,21 @@ async function gql(token, query, variables = {}) {
return json.data;
}
+function buildExpectedPatterns(serviceName) {
+ const scriptFile = `scripts/${serviceName}.mjs`;
+ const patterns = [scriptFile, 'scripts/_seed-utils.mjs', 'scripts/package.json'];
+
+ if (USES_SHARED_CONFIG.has(serviceName)) {
+ patterns.push('scripts/shared/**', 'shared/**');
+ }
+
+ if (serviceName === 'seed-iran-events') {
+ patterns.push('scripts/data/iran-events-latest.json');
+ }
+
+ return patterns;
+}
+
async function main() {
const token = getToken();
@@ -66,15 +85,22 @@ async function main() {
.map(e => e.node)
.filter(s => s.name.startsWith('seed-'));
+ const missingRequiredServices = [...REQUIRED_SEED_SERVICES].filter(
+ (name) => !services.some((service) => service.name === name)
+ );
+ if (missingRequiredServices.length > 0) {
+ throw new Error(`Missing required seed service(s): ${missingRequiredServices.join(', ')}`);
+ }
+
console.log(`Found ${services.length} seed services\n`);
- // 2. Check each service's watchPatterns and startCommand
+ // 2. Check each service's watchPatterns, startCommand, and cronSchedule
for (const svc of services) {
const { service } = await gql(token, `
query ($id: String!, $envId: String!) {
service(id: $id) {
serviceInstances(first: 1, environmentId: $envId) {
- edges { node { watchPatterns startCommand } }
+ edges { node { watchPatterns startCommand cronSchedule } }
}
}
}
@@ -83,26 +109,20 @@ async function main() {
const instance = service.serviceInstances.edges[0]?.node || {};
const currentPatterns = instance.watchPatterns || [];
const currentStartCmd = instance.startCommand || '';
+ const currentCronSchedule = instance.cronSchedule || '';
// rootDirectory="scripts" so startCommand must NOT include the scripts/ prefix
const expectedStartCmd = `node ${svc.name}.mjs`;
const startCmdOk = currentStartCmd === expectedStartCmd;
+ const expectedCronSchedule = EXPECTED_CRON_SCHEDULES.get(svc.name) || '';
+ const hasExpectedCronSchedule = EXPECTED_CRON_SCHEDULES.has(svc.name);
+ const cronScheduleOk = !hasExpectedCronSchedule || currentCronSchedule === expectedCronSchedule;
// Build expected watch patterns (relative to git repo root)
- const scriptFile = `scripts/${svc.name}.mjs`;
- const patterns = [scriptFile, 'scripts/_seed-utils.mjs', 'scripts/package.json'];
-
- if (USES_SHARED_CONFIG.has(svc.name)) {
- patterns.push('scripts/shared/**', 'shared/**');
- }
+ const patterns = buildExpectedPatterns(svc.name);
+ const patternsOk = JSON.stringify([...currentPatterns].sort()) === JSON.stringify([...patterns].sort());
- if (svc.name === 'seed-iran-events') {
- patterns.push('scripts/data/iran-events-latest.json');
- }
-
- const patternsOk = JSON.stringify(currentPatterns.sort()) === JSON.stringify([...patterns].sort());
-
- if (patternsOk && startCmdOk) {
+ if (patternsOk && startCmdOk && cronScheduleOk) {
console.log(` ${svc.name}: already correct`);
continue;
}
@@ -116,6 +136,10 @@ async function main() {
console.log(` watchPatterns current: ${currentPatterns.length ? currentPatterns.join(', ') : '(none)'}`);
console.log(` watchPatterns setting: ${patterns.join(', ')}`);
}
+ if (hasExpectedCronSchedule && !cronScheduleOk) {
+ console.log(` cronSchedule current: ${currentCronSchedule || '(none)'}`);
+ console.log(` cronSchedule expected: ${expectedCronSchedule}`);
+ }
if (DRY_RUN) {
console.log(` [DRY RUN] skipped\n`);
@@ -126,6 +150,7 @@ async function main() {
const input = {};
if (!patternsOk) input.watchPatterns = patterns;
if (!startCmdOk) input.startCommand = expectedStartCmd;
+ if (hasExpectedCronSchedule && !cronScheduleOk) input.cronSchedule = expectedCronSchedule;
await gql(token, `
mutation ($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
diff --git a/scripts/seed-cross-source-signals.mjs b/scripts/seed-cross-source-signals.mjs
index c4579d507b..fdb49617c5 100644
--- a/scripts/seed-cross-source-signals.mjs
+++ b/scripts/seed-cross-source-signals.mjs
@@ -31,6 +31,7 @@ const SOURCE_KEYS = [
'gdelt:intel:tone:maritime',
'weather:alerts:v1',
'risk:scores:sebuf:stale:v1',
+ 'regulatory:actions:v1',
];
// ── Theater classification helpers ────────────────────────────────────────────
@@ -109,6 +110,7 @@ const TYPE_CATEGORY = {
CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME: 'natural',
CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION: 'information',
CROSS_SOURCE_SIGNAL_TYPE_RISK_SCORE_SPIKE: 'intelligence',
+ CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION: 'policy',
};
// Base severity weights for each signal type
@@ -139,6 +141,7 @@ const BASE_WEIGHT = {
CROSS_SOURCE_SIGNAL_TYPE_FORECAST_DETERIORATION: 1.5, // predictive — lower confidence
CROSS_SOURCE_SIGNAL_TYPE_WEATHER_EXTREME: 1.5, // environmental — regional
CROSS_SOURCE_SIGNAL_TYPE_MEDIA_TONE_DETERIORATION: 1.5, // sentiment — lagging
+ CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION: 2.0, // policy action — direct market impact
};
function scoreTier(score) {
@@ -713,6 +716,43 @@ function extractRiskScoreSpike(d) {
});
}
+function extractRegulatoryAction(d) {
+ const payload = d['regulatory:actions:v1'];
+ if (!payload) return [];
+ const cutoff = Date.now() - 48 * 3600 * 1000;
+ const tierPriority = { high: 0, medium: 1 };
+ const recent = (payload.actions || [])
+ .map((action) => ({
+ action,
+ publishedAtTs: safeNum(Date.parse(action.publishedAt)),
+ }))
+ .filter(({ action, publishedAtTs }) => (action.tier === 'high' || action.tier === 'medium') && publishedAtTs > cutoff)
+ .sort((a, b) => {
+ const tierOrder = tierPriority[a.action.tier] - tierPriority[b.action.tier];
+ if (tierOrder !== 0) return tierOrder;
+ return b.publishedAtTs - a.publishedAtTs;
+ })
+ .slice(0, 3);
+ if (recent.length === 0) return [];
+ return recent.map(({ action, publishedAtTs }) => {
+ const tierMult = action.tier === 'high' ? 1.5 : 1.0;
+ const score = BASE_WEIGHT.CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION * tierMult;
+ return {
+ id: `regulatory:${action.id}`,
+ type: 'CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION',
+ // Temporary mapping for the current US-only regulator set; make this
+ // agency-aware when non-US regulatory feeds are added.
+ theater: 'Global Markets',
+ summary: `${action.agency}: ${action.title}`,
+ severity: scoreTier(score),
+ severityScore: score,
+ detectedAt: publishedAtTs || Date.now(),
+ contributingTypes: [],
+ signalCount: 0,
+ };
+ });
+}
+
// ── Composite escalation detector ─────────────────────────────────────────────
// Fires when >=3 signals from DIFFERENT categories share the same theater.
function detectCompositeEscalation(signals) {
@@ -790,6 +830,7 @@ async function aggregateCrossSourceSignals() {
extractWeatherExtreme,
extractMediaToneDeterioration,
extractRiskScoreSpike,
+ extractRegulatoryAction,
];
for (const extractor of extractors) {
diff --git a/scripts/seed-regulatory-actions.mjs b/scripts/seed-regulatory-actions.mjs
new file mode 100644
index 0000000000..a320c23898
--- /dev/null
+++ b/scripts/seed-regulatory-actions.mjs
@@ -0,0 +1,367 @@
+#!/usr/bin/env node
+// @ts-check
+
+import { pathToFileURL } from 'node:url';
+import { CHROME_UA, loadEnvFile, runSeed } from './_seed-utils.mjs';
+
+loadEnvFile(import.meta.url);
+
+const CANONICAL_KEY = 'regulatory:actions:v1';
+const FEED_TIMEOUT_MS = 15_000;
+const TTL_SECONDS = 21600;
+const XML_ACCEPT = 'application/atom+xml, application/rss+xml, application/xml, text/xml, */*';
+const SEC_USER_AGENT = 'WorldMonitor/2.0 (monitor@worldmonitor.app)';
+const DEFAULT_FETCH = (...args) => globalThis.fetch(...args);
+const HIGH_KEYWORDS = [
+ 'enforcement', 'charges', 'charged', 'fraud', 'failure', 'failed bank',
+ 'emergency', 'halt', 'suspension', 'suspended', 'cease', 'desist',
+ 'penalty', 'fine', 'fined', 'settlement', 'indictment', 'manipulation',
+ 'ban', 'revocation', 'insolvency', 'injunction', 'cease and desist',
+ 'cease-and-desist', 'consent order', 'debarment', 'suspension order',
+];
+const MEDIUM_KEYWORDS = [
+ 'proposed rule', 'final rule', 'rulemaking', 'guidance', 'warning',
+ 'advisory', 'review', 'examination', 'investigation',
+ 'stress test', 'capital requirement', 'disclosure requirement',
+ 'resolves action', 'settled charges', 'administrative proceeding', 'remedial action',
+];
+const LOW_PRIORITY_TITLE_PATTERNS = [
+ /^(Regulatory|Information|Technical) Notice\b/i,
+ /\bmonthly (highlights|bulletin)\b/i,
+];
+
+const REGULATORY_FEEDS = [
+ { agency: 'SEC', url: 'https://www.sec.gov/news/pressreleases.rss', userAgent: SEC_USER_AGENT },
+ { agency: 'CFTC', url: 'https://www.cftc.gov/RSS/RSSENF/rssenf.xml' },
+ { agency: 'Federal Reserve', url: 'https://www.federalreserve.gov/feeds/press_all.xml' },
+ { agency: 'FDIC', url: 'https://public.govdelivery.com/topics/USFDIC_26/feed.rss' },
+ // FINRA still publishes this RSS endpoint over plain HTTP; HTTPS requests fail
+ // from both Node fetch and curl in validation, so keep the official feed URL
+ // and periodically recheck whether HTTPS starts working.
+ { agency: 'FINRA', url: 'http://feeds.finra.org/FINRANotices' },
+];
+
+function decodeEntities(input) {
+ if (!input) return '';
+ const named = input
+ .replace(/&/gi, '&')
+ .replace(/</gi, '<')
+ .replace(/>/gi, '>')
+ .replace(/"/gi, '"')
+ .replace(/'/gi, "'")
+ .replace(/ /gi, ' ');
+
+ return named
+ .replace(/(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
+ .replace(/([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
+}
+
+function stripHtml(input) {
+ return decodeEntities(
+ String(input || '')
+ .replace(//g, '$1')
+ .replace(/<[^>]+>/g, ' ')
+ ).replace(/\s+/g, ' ').trim();
+}
+
+function getTagValue(block, tagName) {
+ const match = block.match(new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i'));
+ return stripHtml(match?.[1] || '');
+}
+
+function extractAtomLink(block) {
+ const linkTags = [...block.matchAll(/]*)\/?>/gi)];
+ if (linkTags.length === 0) return '';
+
+ for (const [, attrs] of linkTags) {
+ const href = attrs.match(/\bhref=["']([^"']+)["']/i)?.[1];
+ const rel = attrs.match(/\brel=["']([^"']+)["']/i)?.[1]?.toLowerCase() || '';
+ if (href && (!rel || rel === 'alternate')) return decodeEntities(href.trim());
+ }
+
+ for (const [, attrs] of linkTags) {
+ const href = attrs.match(/\bhref=["']([^"']+)["']/i)?.[1];
+ if (href) return decodeEntities(href.trim());
+ }
+
+ return '';
+}
+
+function resolveFeedLink(link, feedUrl) {
+ if (!link) return '';
+ try {
+ return new URL(link).href;
+ } catch {}
+ try {
+ return new URL(link, feedUrl).href;
+ } catch {
+ return '';
+ }
+}
+
+function canonicalizeLink(link, feedUrl = '') {
+ const resolved = resolveFeedLink(link, feedUrl);
+ if (!resolved) return '';
+ try {
+ const url = new URL(resolved);
+ url.hash = '';
+ return url.href;
+ } catch {
+ return '';
+ }
+}
+
+function toIsoDate(rawDate) {
+ const value = stripHtml(rawDate);
+ if (!value) return '';
+ const ts = Date.parse(value);
+ return Number.isFinite(ts) ? new Date(ts).toISOString() : '';
+}
+
+function slugifyTitle(title) {
+ return stripHtml(title)
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80);
+}
+
+function yyyymmdd(isoDate) {
+ return String(isoDate || '').slice(0, 10).replace(/-/g, '');
+}
+
+function hhmmss(isoDate) {
+ return String(isoDate || '').slice(11, 19).replace(/:/g, '');
+}
+
+function buildActionId(agency, title, publishedAt) {
+ const agencySlug = slugifyTitle(agency) || 'agency';
+ const titleSlug = slugifyTitle(title) || 'untitled';
+ const datePart = yyyymmdd(publishedAt) || 'undated';
+ const timePart = hhmmss(publishedAt) || '000000';
+ return `${agencySlug}-${titleSlug}-${datePart}-${timePart}`;
+}
+
+function parseRssItems(xml, feedUrl) {
+ const items = [];
+ const itemRegex = /- ]*>([\s\S]*?)<\/item>/gi;
+ let match;
+ while ((match = itemRegex.exec(xml)) !== null) {
+ const block = match[1];
+ const title = getTagValue(block, 'title');
+ const description = getTagValue(block, 'description');
+ const link = canonicalizeLink(getTagValue(block, 'link'), feedUrl);
+ const publishedAt = toIsoDate(getTagValue(block, 'pubDate') || getTagValue(block, 'updated'));
+ items.push({ title, description, link, publishedAt });
+ }
+ return items;
+}
+
+function parseAtomEntries(xml, feedUrl) {
+ const entries = [];
+ const entryRegex = /]*>([\s\S]*?)<\/entry>/gi;
+ let match;
+ while ((match = entryRegex.exec(xml)) !== null) {
+ const block = match[1];
+ const title = getTagValue(block, 'title');
+ const description = getTagValue(block, 'summary') || getTagValue(block, 'content');
+ const link = canonicalizeLink(extractAtomLink(block), feedUrl);
+ const publishedAt = toIsoDate(
+ getTagValue(block, 'updated') || getTagValue(block, 'published') || getTagValue(block, 'pubDate')
+ );
+ entries.push({ title, description, link, publishedAt });
+ }
+ return entries;
+}
+
+function parseFeed(xml, feedUrl) {
+ if (/ item.title && item.link && item.publishedAt)
+ .map((item) => ({
+ id: buildActionId(agency, item.title, item.publishedAt),
+ agency,
+ title: item.title,
+ description: item.description || '',
+ link: item.link,
+ publishedAt: item.publishedAt,
+ }));
+}
+
+function dedupeAndSortActions(actions) {
+ const seen = new Set();
+ const deduped = [];
+ for (const action of actions) {
+ const key = canonicalizeLink(action.link);
+ if (!key || seen.has(key)) continue;
+ seen.add(key);
+ deduped.push({ ...action, link: key });
+ }
+
+ deduped.sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
+ return deduped;
+}
+
+async function fetchFeed(feed, fetchImpl = DEFAULT_FETCH) {
+ const headers = {
+ Accept: XML_ACCEPT,
+ 'User-Agent': feed.userAgent || CHROME_UA,
+ };
+
+ const response = await fetchImpl(feed.url, {
+ headers,
+ signal: AbortSignal.timeout(FEED_TIMEOUT_MS),
+ });
+
+ if (!response.ok) {
+ throw new Error(`${feed.agency}: HTTP ${response.status}`);
+ }
+
+ const xml = await response.text();
+ const parsed = parseFeed(xml, feed.url);
+ return normalizeFeedItems(parsed, feed.agency);
+}
+
+async function fetchAllFeeds(fetchImpl = DEFAULT_FETCH, feeds = REGULATORY_FEEDS) {
+ const results = await Promise.allSettled(feeds.map((feed) => fetchFeed(feed, fetchImpl)));
+ const actions = [];
+ let successCount = 0;
+
+ for (let index = 0; index < results.length; index += 1) {
+ const result = results[index];
+ const feed = feeds[index];
+ if (result.status === 'fulfilled') {
+ successCount += 1;
+ actions.push(...result.value);
+ continue;
+ }
+ console.error(`[regulatory] ${feed.agency}: ${result.reason?.message || result.reason}`);
+ }
+
+ if (successCount === 0) {
+ throw new Error('All regulatory feeds failed');
+ }
+
+ return dedupeAndSortActions(actions);
+}
+
+function escapeRegex(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function compileKeywordPattern(keyword) {
+ const pattern = `\\b${escapeRegex(keyword.toLowerCase()).replace(/\s+/g, '\\s+')}\\b`;
+ return { keyword, regex: new RegExp(pattern, 'i') };
+}
+
+const HIGH_KEYWORD_PATTERNS = HIGH_KEYWORDS.map(compileKeywordPattern);
+const MEDIUM_KEYWORD_PATTERNS = MEDIUM_KEYWORDS.map(compileKeywordPattern);
+
+function findMatchedKeywords(text, keywordPatterns) {
+ const normalizedText = stripHtml(text).toLowerCase();
+ return keywordPatterns.filter(({ regex }) => regex.test(normalizedText)).map(({ keyword }) => keyword);
+}
+
+function buildClassificationText(action) {
+ return [action.title, action.description].filter(Boolean).join(' ');
+}
+
+function isLowPriorityRoutineTitle(title) {
+ const normalizedTitle = stripHtml(title);
+ return LOW_PRIORITY_TITLE_PATTERNS.some((pattern) => pattern.test(normalizedTitle));
+}
+
+function classifyAction(action) {
+ const classificationText = buildClassificationText(action);
+ const highMatches = findMatchedKeywords(classificationText, HIGH_KEYWORD_PATTERNS);
+ if (highMatches.length > 0) {
+ return { ...action, tier: 'high', matchedKeywords: [...new Set(highMatches)] };
+ }
+
+ if (isLowPriorityRoutineTitle(action.title)) {
+ return { ...action, tier: 'low', matchedKeywords: [] };
+ }
+
+ const mediumMatches = findMatchedKeywords(classificationText, MEDIUM_KEYWORD_PATTERNS);
+ if (mediumMatches.length > 0) {
+ return { ...action, tier: 'medium', matchedKeywords: [...new Set(mediumMatches)] };
+ }
+
+ return { ...action, tier: 'unknown', matchedKeywords: [] };
+}
+
+function buildSeedPayload(actions, fetchedAt = Date.now()) {
+ const classified = actions.map(classifyAction);
+ const highCount = classified.filter((action) => action.tier === 'high').length;
+ const mediumCount = classified.filter((action) => action.tier === 'medium').length;
+
+ return {
+ actions: classified,
+ fetchedAt,
+ recordCount: classified.length,
+ highCount,
+ mediumCount,
+ };
+}
+
+async function fetchRegulatoryActionPayload(fetchImpl = DEFAULT_FETCH) {
+ const actions = await fetchAllFeeds(fetchImpl);
+ return buildSeedPayload(actions, Date.now());
+}
+
+async function main(fetchImpl = DEFAULT_FETCH, runSeedImpl = runSeed) {
+ return runSeedImpl('regulatory', 'actions', CANONICAL_KEY, () => fetchRegulatoryActionPayload(fetchImpl), {
+ ttlSeconds: TTL_SECONDS,
+ validateFn: (data) => Array.isArray(data?.actions),
+ recordCount: (data) => data?.recordCount || 0,
+ sourceVersion: 'regulatory-rss-v1',
+ });
+}
+
+const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
+
+if (isDirectRun) {
+ main().catch((err) => {
+ console.error(`FETCH FAILED: ${err.message || err}`);
+ process.exit(1);
+ });
+}
+
+export {
+ CANONICAL_KEY,
+ CHROME_UA,
+ FEED_TIMEOUT_MS,
+ HIGH_KEYWORDS,
+ MEDIUM_KEYWORDS,
+ REGULATORY_FEEDS,
+ SEC_USER_AGENT,
+ TTL_SECONDS,
+ buildActionId,
+ buildSeedPayload,
+ canonicalizeLink,
+ classifyAction,
+ decodeEntities,
+ dedupeAndSortActions,
+ extractAtomLink,
+ fetchAllFeeds,
+ fetchFeed,
+ fetchRegulatoryActionPayload,
+ findMatchedKeywords,
+ getTagValue,
+ isLowPriorityRoutineTitle,
+ main,
+ normalizeFeedItems,
+ parseAtomEntries,
+ parseFeed,
+ parseRssItems,
+ resolveFeedLink,
+ slugifyTitle,
+ stripHtml,
+ toIsoDate,
+};
diff --git a/server/_shared/cache-keys.ts b/server/_shared/cache-keys.ts
index fb48505afd..f656e6fe86 100644
--- a/server/_shared/cache-keys.ts
+++ b/server/_shared/cache-keys.ts
@@ -5,6 +5,7 @@
*/
export const SIMULATION_OUTCOME_LATEST_KEY = 'forecast:simulation-outcome:latest';
export const SIMULATION_PACKAGE_LATEST_KEY = 'forecast:simulation-package:latest';
+export const REGULATORY_ACTIONS_KEY = 'regulatory:actions:v1';
/**
* Static cache keys for the bootstrap endpoint.
diff --git a/tests/cross-source-signals-regulatory.test.mjs b/tests/cross-source-signals-regulatory.test.mjs
new file mode 100644
index 0000000000..6a64ef33e5
--- /dev/null
+++ b/tests/cross-source-signals-regulatory.test.mjs
@@ -0,0 +1,179 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+
+function normalize(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+const seedSrc = readFileSync('scripts/seed-cross-source-signals.mjs', 'utf8');
+
+const pureSrc = seedSrc
+ .replace(/^import\s.*$/gm, '')
+ .replace(/loadEnvFile\([^)]+\);\n/, '')
+ .replace(/async function readAllSourceKeys[\s\S]*?\n}\n\n\/\/ ── Signal extractors/m, '// readAllSourceKeys removed for unit test\n\n// ── Signal extractors')
+ .replace(/runSeed\('intelligence'[\s\S]*$/m, '');
+
+const ctx = vm.createContext({ console, Date, Math, Number, Array, Map, Set, String, RegExp });
+vm.runInContext(`${pureSrc}\n;globalThis.__exports = { SOURCE_KEYS, TYPE_CATEGORY, BASE_WEIGHT, scoreTier, extractRegulatoryAction, detectCompositeEscalation };`, ctx);
+
+const {
+ SOURCE_KEYS,
+ TYPE_CATEGORY,
+ BASE_WEIGHT,
+ scoreTier,
+ extractRegulatoryAction,
+ detectCompositeEscalation,
+} = ctx.__exports;
+
+describe('source registration', () => {
+ it('adds the regulatory seed key to SOURCE_KEYS', () => {
+ assert.ok(SOURCE_KEYS.includes('regulatory:actions:v1'));
+ });
+
+ it('maps regulatory actions to the policy category and a 2.0 base weight', () => {
+ assert.equal(TYPE_CATEGORY.CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION, 'policy');
+ assert.equal(BASE_WEIGHT.CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION, 2.0);
+ });
+
+ it('registers the extractor in the extractor list', () => {
+ assert.match(seedSrc, /extractRegulatoryAction,\n\s*];/);
+ });
+});
+
+describe('extractRegulatoryAction', () => {
+ it('returns an empty array when the source key is missing', () => {
+ assert.deepEqual(normalize(extractRegulatoryAction({})), []);
+ });
+
+ it('emits only high and medium signals, prioritizes high before fresher medium, and limits output to 3', () => {
+ const now = Date.now();
+ const payload = {
+ 'regulatory:actions:v1': {
+ actions: [
+ {
+ id: 'fdic-a',
+ agency: 'FDIC',
+ title: 'FDIC Guidance Update',
+ publishedAt: new Date(now - 1 * 3600 * 1000).toISOString(),
+ tier: 'medium',
+ },
+ {
+ id: 'sec-c',
+ agency: 'SEC',
+ title: 'SEC Settlement',
+ publishedAt: new Date(now - 5 * 3600 * 1000).toISOString(),
+ tier: 'high',
+ },
+ {
+ id: 'sec-a',
+ agency: 'SEC',
+ title: 'SEC Charges Issuer',
+ publishedAt: new Date(now - 2 * 3600 * 1000).toISOString(),
+ tier: 'high',
+ },
+ {
+ id: 'finra-low',
+ agency: 'FINRA',
+ title: 'FINRA Monthly Bulletin',
+ publishedAt: new Date(now - 3 * 3600 * 1000).toISOString(),
+ tier: 'low',
+ },
+ {
+ id: 'cftc-b',
+ agency: 'CFTC',
+ title: 'CFTC Advisory Notice',
+ publishedAt: new Date(now - 4 * 3600 * 1000).toISOString(),
+ tier: 'medium',
+ },
+ {
+ id: 'fed-unknown',
+ agency: 'Federal Reserve',
+ title: 'Federal Reserve outreach update',
+ publishedAt: new Date(now - 30 * 60 * 1000).toISOString(),
+ tier: 'unknown',
+ },
+ {
+ id: 'fdic-invalid',
+ agency: 'FDIC',
+ title: 'FDIC malformed timestamp',
+ publishedAt: 'not-a-date',
+ tier: 'high',
+ },
+ {
+ id: 'fed-old',
+ agency: 'Federal Reserve',
+ title: 'Old Enforcement Notice',
+ publishedAt: new Date(now - 72 * 3600 * 1000).toISOString(),
+ tier: 'high',
+ },
+ ],
+ },
+ };
+
+ const signals = normalize(extractRegulatoryAction(payload));
+ assert.equal(signals.length, 3);
+ assert.deepEqual(signals.map((signal) => signal.id), [
+ 'regulatory:sec-a',
+ 'regulatory:sec-c',
+ 'regulatory:fdic-a',
+ ]);
+ assert.equal(signals[0].severityScore, 3.0);
+ assert.equal(signals[0].severity, 'CROSS_SOURCE_SIGNAL_SEVERITY_HIGH');
+ assert.equal(signals[1].severityScore, 3.0);
+ assert.equal(signals[1].severity, 'CROSS_SOURCE_SIGNAL_SEVERITY_HIGH');
+ assert.equal(signals[2].severityScore, 2.0);
+ assert.equal(signals[2].severity, 'CROSS_SOURCE_SIGNAL_SEVERITY_MEDIUM');
+ assert.equal(signals[0].theater, 'Global Markets');
+ assert.equal(signals[0].summary, 'SEC: SEC Charges Issuer');
+ assert.ok(signals.every((signal) => Number.isFinite(signal.detectedAt)));
+ });
+});
+
+describe('detectCompositeEscalation', () => {
+ it('fires when policy, financial, and economic signals co-fire in Global Markets', () => {
+ const composite = normalize(detectCompositeEscalation([
+ {
+ id: 'regulatory:sec-a',
+ type: 'CROSS_SOURCE_SIGNAL_TYPE_REGULATORY_ACTION',
+ theater: 'Global Markets',
+ summary: 'SEC: SEC Charges Issuer',
+ severity: scoreTier(3.0),
+ severityScore: 3.0,
+ detectedAt: Date.now(),
+ contributingTypes: [],
+ signalCount: 0,
+ },
+ {
+ id: 'vix:global',
+ type: 'CROSS_SOURCE_SIGNAL_TYPE_VIX_SPIKE',
+ theater: 'Global Markets',
+ summary: 'VIX elevated',
+ severity: scoreTier(2.0),
+ severityScore: 2.0,
+ detectedAt: Date.now(),
+ contributingTypes: [],
+ signalCount: 0,
+ },
+ {
+ id: 'commodity:oil',
+ type: 'CROSS_SOURCE_SIGNAL_TYPE_COMMODITY_SHOCK',
+ theater: 'Global Markets',
+ summary: 'Oil shock',
+ severity: scoreTier(2.0),
+ severityScore: 2.0,
+ detectedAt: Date.now(),
+ contributingTypes: [],
+ signalCount: 0,
+ },
+ ]));
+
+ assert.equal(composite.length, 1);
+ assert.equal(composite[0].type, 'CROSS_SOURCE_SIGNAL_TYPE_COMPOSITE_ESCALATION');
+ assert.equal(composite[0].theater, 'Global Markets');
+ assert.ok(composite[0].contributingTypes.includes('regulatory action'));
+ assert.ok(composite[0].contributingTypes.includes('vix spike'));
+ assert.ok(composite[0].contributingTypes.includes('commodity shock'));
+ });
+});
diff --git a/tests/railway-set-watch-paths.test.mjs b/tests/railway-set-watch-paths.test.mjs
new file mode 100644
index 0000000000..528a362ffb
--- /dev/null
+++ b/tests/railway-set-watch-paths.test.mjs
@@ -0,0 +1,26 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+
+const scriptSrc = readFileSync('scripts/railway-set-watch-paths.mjs', 'utf8');
+
+describe('railway-set-watch-paths regulatory cron sync', () => {
+ it('declares seed-regulatory-actions as a required seed service', () => {
+ assert.match(scriptSrc, /REQUIRED_SEED_SERVICES = new Set\(\['seed-regulatory-actions'\]\)/);
+ assert.match(scriptSrc, /Missing required seed service\(s\):/);
+ });
+
+ it('defines the expected 2-hour cron schedule for seed-regulatory-actions', () => {
+ assert.match(scriptSrc, /EXPECTED_CRON_SCHEDULES = new Map\(\[\s*\['seed-regulatory-actions', '0 \*\/2 \* \* \*'\]/s);
+ });
+
+ it('queries and updates cronSchedule via serviceInstanceUpdate', () => {
+ assert.match(scriptSrc, /node \{ watchPatterns startCommand cronSchedule \}/);
+ assert.match(scriptSrc, /input\.cronSchedule = expectedCronSchedule/);
+ });
+
+ it('continues to derive watch patterns from the seed name', () => {
+ assert.match(scriptSrc, /function buildExpectedPatterns\(serviceName\)/);
+ assert.match(scriptSrc, /const scriptFile = `scripts\/\$\{serviceName\}\.mjs`/);
+ });
+});
diff --git a/tests/regulatory-contract.test.mjs b/tests/regulatory-contract.test.mjs
new file mode 100644
index 0000000000..9757ddf1a1
--- /dev/null
+++ b/tests/regulatory-contract.test.mjs
@@ -0,0 +1,34 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = join(__dirname, '..');
+
+describe('regulatory cache contracts', () => {
+ it('exports REGULATORY_ACTIONS_KEY from cache-keys.ts', () => {
+ const cacheKeysSrc = readFileSync(join(root, 'server', '_shared', 'cache-keys.ts'), 'utf8');
+ assert.match(
+ cacheKeysSrc,
+ /export const REGULATORY_ACTIONS_KEY = 'regulatory:actions:v1';/
+ );
+ });
+
+ it('registers regulatoryActions in health standalone keys', () => {
+ const healthSrc = readFileSync(join(root, 'api', 'health.js'), 'utf8');
+ assert.match(
+ healthSrc,
+ /regulatoryActions:\s+'regulatory:actions:v1'/
+ );
+ });
+
+ it('registers regulatoryActions seed freshness metadata in health', () => {
+ const healthSrc = readFileSync(join(root, 'api', 'health.js'), 'utf8');
+ assert.match(
+ healthSrc,
+ /regulatoryActions:\s+\{\s+key:\s+'seed-meta:regulatory:actions',\s+maxStaleMin:\s+240\s+\}/
+ );
+ });
+});
diff --git a/tests/regulatory-seed-unit.test.mjs b/tests/regulatory-seed-unit.test.mjs
new file mode 100644
index 0000000000..02323218b7
--- /dev/null
+++ b/tests/regulatory-seed-unit.test.mjs
@@ -0,0 +1,359 @@
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import vm from 'node:vm';
+
+function normalize(value) {
+ return JSON.parse(JSON.stringify(value));
+}
+
+const seedSrc = readFileSync('scripts/seed-regulatory-actions.mjs', 'utf8');
+
+const pureSrc = seedSrc
+ .replace(/^import\s.*$/gm, '')
+ .replace(/loadEnvFile\([^)]+\);\n/, '')
+ .replace(/const isDirectRun[\s\S]*?}\n\nexport\s*{[\s\S]*?};?\s*$/m, '');
+
+const ctx = vm.createContext({
+ console,
+ Date,
+ Math,
+ Number,
+ Array,
+ Set,
+ String,
+ RegExp,
+ URL,
+ URLSearchParams,
+ AbortSignal,
+ CHROME_UA: 'Mozilla/5.0 (test)',
+ loadEnvFile: () => {},
+ runSeed: async () => {},
+});
+
+vm.runInContext(pureSrc, ctx);
+
+const {
+ decodeEntities,
+ stripHtml,
+ extractAtomLink,
+ parseRssItems,
+ parseAtomEntries,
+ parseFeed,
+ normalizeFeedItems,
+ dedupeAndSortActions,
+ fetchAllFeeds,
+ classifyAction,
+ buildSeedPayload,
+ fetchRegulatoryActionPayload,
+ main,
+} = ctx;
+
+describe('decodeEntities', () => {
+ it('decodes named and numeric entities', () => {
+ assert.equal(decodeEntities('Tom & Jerry & &'), 'Tom & Jerry & &');
+ });
+});
+
+describe('stripHtml', () => {
+ it('removes tags and CDATA while preserving text', () => {
+ assert.equal(stripHtml('world]]>'), 'Hello world');
+ });
+});
+
+describe('parseRssItems', () => {
+ it('extracts RSS items with description, normalized links, and pubDate', () => {
+ const xml = `
+
+
-
+ Issuer]]>
+ fraud & disclosure failures]]>
+ /news/press-release/2026-10
+ Mon, 30 Mar 2026 18:00:00 GMT
+
+ `;
+
+ assert.deepEqual(normalize(parseRssItems(xml, 'https://www.sec.gov/news/pressreleases.rss')), [{
+ title: 'SEC & Co. Charges Issuer',
+ description: 'Alleges fraud & disclosure failures',
+ link: 'https://www.sec.gov/news/press-release/2026-10',
+ publishedAt: '2026-03-30T18:00:00.000Z',
+ }]);
+ });
+});
+
+describe('extractAtomLink + parseAtomEntries', () => {
+ it('prefers alternate href and extracts summary/content with normalized publishedAt', () => {
+ const xml = `
+
+
+ Fed issues notice
+ policy summary]]>
+
+
+ 2026-03-29T12:30:00Z
+
+ `;
+
+ assert.equal(
+ extractAtomLink(''),
+ '/press/notice-a'
+ );
+
+ assert.deepEqual(normalize(parseAtomEntries(xml, 'https://www.federalreserve.gov/feeds/press_all.xml')), [{
+ title: 'Fed issues notice',
+ description: 'Detailed policy summary',
+ link: 'https://www.federalreserve.gov/press/notice-a',
+ publishedAt: '2026-03-29T12:30:00.000Z',
+ }]);
+
+ const contentXml = `
+
+
+ FDIC update
+ Formal administrative note
]]>
+
+ 2026-03-28T09:15:00Z
+
+ `;
+
+ assert.deepEqual(normalize(parseAtomEntries(contentXml, 'https://www.fdic.gov/feed')), [{
+ title: 'FDIC update',
+ description: 'Formal administrative note',
+ link: 'https://fdic.example.test/a',
+ publishedAt: '2026-03-28T09:15:00.000Z',
+ }]);
+ });
+});
+
+describe('parseFeed', () => {
+ it('detects Atom feeds automatically', () => {
+ const atom = 'A2026-03-28T00:00:00Z';
+ const parsed = normalize(parseFeed(atom, 'https://example.test/feed'));
+ assert.equal(parsed.length, 1);
+ assert.equal(parsed[0].link, 'https://example.test/a');
+ });
+});
+
+describe('normalizeFeedItems', () => {
+ it('skips incomplete entries and generates deterministic ids', () => {
+ const normalized = normalize(normalizeFeedItems([
+ { title: 'SEC Charges XYZ Corp', link: 'https://example.test/sec', publishedAt: '2026-03-29T14:00:00.000Z' },
+ { title: 'SEC Summary', description: 'extra context', link: 'https://example.test/sec-2', publishedAt: '2026-03-29T14:30:00.000Z' },
+ { title: '', link: 'https://example.test/missing', publishedAt: '2026-03-29T14:00:00.000Z' },
+ ], 'SEC'));
+
+ assert.equal(normalized.length, 2);
+ assert.equal(normalized[0].id, 'sec-sec-charges-xyz-corp-20260329-140000');
+ assert.equal(normalized[0].description, '');
+ assert.equal(normalized[1].description, 'extra context');
+ });
+});
+
+describe('dedupeAndSortActions', () => {
+ it('deduplicates by canonical link and sorts newest first', () => {
+ const actions = normalize(dedupeAndSortActions([
+ {
+ id: 'older',
+ agency: 'SEC',
+ title: 'Older',
+ link: 'https://example.test/path#frag',
+ publishedAt: '2026-03-28T10:00:00.000Z',
+ },
+ {
+ id: 'newer',
+ agency: 'FDIC',
+ title: 'Newer',
+ link: 'https://example.test/new',
+ publishedAt: '2026-03-30T10:00:00.000Z',
+ },
+ {
+ id: 'duplicate',
+ agency: 'SEC',
+ title: 'Duplicate',
+ link: 'https://example.test/path',
+ publishedAt: '2026-03-29T10:00:00.000Z',
+ },
+ ]));
+
+ assert.deepEqual(actions.map((item) => item.id), ['newer', 'older']);
+ assert.equal(actions[1].link, 'https://example.test/path');
+ });
+});
+
+describe('fetchAllFeeds', () => {
+ const feeds = [
+ { agency: 'SEC', url: 'https://feeds.test/sec', userAgent: 'Custom-SEC-UA' },
+ { agency: 'FDIC', url: 'https://feeds.test/fdic' },
+ ];
+
+ it('returns normalized aggregate when at least one feed succeeds', async () => {
+ const requests = [];
+ const fetchStub = async (url, options) => {
+ requests.push({ url, options });
+ if (url.endsWith('/sec')) {
+ return {
+ ok: true,
+ text: async () => `- SEC Charges Bankhttps://sec.test/aMon, 30 Mar 2026 18:00:00 GMT
`,
+ };
+ }
+ throw new Error('FDIC timeout');
+ };
+
+ const result = normalize(await fetchAllFeeds(fetchStub, feeds));
+ assert.equal(result.length, 1);
+ assert.equal(result[0].agency, 'SEC');
+ assert.equal(requests[0].options.headers['User-Agent'], 'Custom-SEC-UA');
+ assert.equal(requests[1].options.headers['User-Agent'], ctx.CHROME_UA);
+ });
+
+ it('throws when all feeds fail', async () => {
+ await assert.rejects(
+ fetchAllFeeds(async () => { throw new Error('nope'); }, feeds),
+ /All regulatory feeds failed/
+ );
+ });
+});
+
+describe('classifyAction', () => {
+ it('marks high priority actions from combined title and description text', () => {
+ const action = normalize(classifyAction({
+ id: 'sec-a',
+ agency: 'SEC',
+ title: 'SEC action against issuer',
+ description: 'The SEC secured a permanent injunction for accounting fraud.',
+ link: 'https://example.test/sec-a',
+ publishedAt: '2026-03-30T18:00:00.000Z',
+ }));
+
+ assert.equal(action.tier, 'high');
+ assert.deepEqual(action.matchedKeywords, ['fraud', 'injunction']);
+ });
+
+ it('marks medium actions from description text', () => {
+ const medium = normalize(classifyAction({
+ id: 'fed-a',
+ agency: 'Federal Reserve',
+ title: 'Federal Reserve update',
+ description: 'The board resolves action through a remedial action plan.',
+ link: 'https://example.test/fed-a',
+ publishedAt: '2026-03-30T18:00:00.000Z',
+ }));
+
+ assert.equal(medium.tier, 'medium');
+ assert.deepEqual(medium.matchedKeywords, ['resolves action', 'remedial action']);
+ });
+
+ it('uses low only for explicit routine notice titles', () => {
+ const low = normalize(classifyAction({
+ id: 'finra-a',
+ agency: 'FINRA',
+ title: 'Technical Notice 26-01',
+ description: 'Routine operational bulletin for members.',
+ link: 'https://example.test/finra-a',
+ publishedAt: '2026-03-30T18:00:00.000Z',
+ }));
+
+ assert.equal(low.tier, 'low');
+ assert.deepEqual(low.matchedKeywords, []);
+ });
+
+ it('falls back to unknown for unmatched actions', () => {
+ const unknown = normalize(classifyAction({
+ id: 'fdic-a',
+ agency: 'FDIC',
+ title: 'FDIC consumer outreach update',
+ description: 'General event recap for community stakeholders.',
+ link: 'https://example.test/fdic-a',
+ publishedAt: '2026-03-30T18:00:00.000Z',
+ }));
+
+ assert.equal(unknown.tier, 'unknown');
+ assert.deepEqual(unknown.matchedKeywords, []);
+ });
+});
+
+describe('buildSeedPayload', () => {
+ it('adds fetchedAt and aggregate counts', () => {
+ const payload = normalize(buildSeedPayload([
+ {
+ id: 'sec-a',
+ agency: 'SEC',
+ title: 'SEC action against issuer',
+ description: 'The SEC secured a permanent injunction for accounting fraud.',
+ link: 'https://example.test/sec-a',
+ publishedAt: '2026-03-30T18:00:00.000Z',
+ },
+ {
+ id: 'fed-a',
+ agency: 'Federal Reserve',
+ title: 'Federal Reserve update',
+ description: 'The board resolves action through a remedial action plan.',
+ link: 'https://example.test/fed-a',
+ publishedAt: '2026-03-29T18:00:00.000Z',
+ },
+ {
+ id: 'finra-a',
+ agency: 'FINRA',
+ title: 'Regulatory Notice 26-01',
+ description: 'Routine bulletin for members.',
+ link: 'https://example.test/finra-a',
+ publishedAt: '2026-03-28T18:00:00.000Z',
+ },
+ {
+ id: 'fdic-a',
+ agency: 'FDIC',
+ title: 'FDIC consumer outreach update',
+ description: 'General event recap for community stakeholders.',
+ link: 'https://example.test/fdic-a',
+ publishedAt: '2026-03-27T18:00:00.000Z',
+ },
+ ], 1711718400000));
+
+ assert.equal(payload.fetchedAt, 1711718400000);
+ assert.equal(payload.recordCount, 4);
+ assert.equal(payload.highCount, 1);
+ assert.equal(payload.mediumCount, 1);
+ assert.equal(payload.actions[2].tier, 'low');
+ assert.equal(payload.actions[3].tier, 'unknown');
+ });
+});
+
+describe('fetchRegulatoryActionPayload', () => {
+ it('returns classified payload from fetched actions', async () => {
+ const payload = normalize(await fetchRegulatoryActionPayload(async (url) => ({
+ ok: true,
+ text: async () => `- FDIC updateFDIC resolves action through a remedial action plan.${url}/itemMon, 30 Mar 2026 18:00:00 GMT
`,
+ })));
+
+ assert.equal(payload.actions.length, 5);
+ assert.equal(payload.recordCount, 5);
+ assert.ok(typeof payload.fetchedAt === 'number');
+ assert.equal(payload.actions[0].tier, 'medium');
+ assert.deepEqual(payload.actions[0].matchedKeywords, ['resolves action', 'remedial action']);
+ });
+});
+
+describe('main', () => {
+ it('wires runSeed with the regulatory key, TTL, and validateFn', async () => {
+ const calls = [];
+ const runSeedStub = async (domain, resource, canonicalKey, fetchFn, opts) => {
+ calls.push({ domain, resource, canonicalKey, opts, payload: await fetchFn() });
+ return 'ok';
+ };
+ const fetchStub = async (url) => ({
+ ok: true,
+ text: async () => `- CFTC Issues Advisory${url}/itemMon, 30 Mar 2026 18:00:00 GMT
`,
+ });
+
+ const result = await main(fetchStub, runSeedStub);
+ assert.equal(result, 'ok');
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].domain, 'regulatory');
+ assert.equal(calls[0].resource, 'actions');
+ assert.equal(calls[0].canonicalKey, 'regulatory:actions:v1');
+ assert.equal(calls[0].opts.ttlSeconds, 21600);
+ assert.equal(calls[0].opts.validateFn({ actions: [] }), true);
+ assert.equal(calls[0].payload.recordCount, 5);
+ });
+});